Files
Flaro/Tools/JellyfinStub/server.py
T
ScarriffleandClaude Opus 5 b0db4f8f5f Bei VLC sass das Bild nie richtig — und der Knopf wirkte nie
Zwei Fehler, die dieselbe Ursache haben: alles, was über VLC läuft, ging
an der Bildebene vorbei, an der die App gedreht hat.

**Das Bild sass nicht mittig.** Nicht ein paar Punkte daneben — gemessen
eine Anzeigefläche von 1200×480 Punkt in einer Zeichenfläche von 711×400,
unten rechts angeschlagen, im falschen Seitenverhältnis. Der Grund steht
im Vertrag von `VLCDrawable`: VLCKit fragt die Grösse **einmal** beim
Anlegen ab und überlässt seine Anzeigefläche danach der
Autoresizing-Maske. Eine Maske hält aber den Unterschied fest, nicht das
Verhältnis — was beim Anlegen zu gross war, bleibt für immer um denselben
Betrag zu gross. Und angelegt wird sie mitten in der Drehung ins
Querformat, während SwiftUI sie einblendet: also regelmässig falsch.

Die Zeichenfläche ist jetzt eine eigene Klasse, die ihrer Anzeigefläche
bei jedem Layout wieder die eigene Grösse gibt. Danach gemessen: 695,7 ×
400 Punkt, Rand links 130,0, rechts 130,3 — mittig.

**Der Knopf für einpassen/füllen tat nichts.** Er schrieb `videoGravity`
auf eine `AVPlayerLayer`, die bei VLC gar nicht im Baum hängt. Die
Einstellung geht deshalb jetzt durch die Engine: `setVideoFit` am
Protokoll, beim Systemplayer eine Voreinstellung, die nichts tut — dort
macht es die Bildebene —, bei VLC `videoFitMode`. `smaller` heisst bei
libvlc „fit inside", `larger` „fit outside".

Gemessen, im Querformat auf einem Pro Max, über VLC:

    eingepasst   695,7 × 400 pt   Rand l/r 130,0/130,3   o/u 20,0/20,0
    füllend      935,3 × 440 pt   Rand l/r  10,3/ 10,3   o/u  0,0/ 0,0

**Zurückgenommen:** im Kommentar an `videoFit` stand, ein `didSet` auf
einer `@Observable`-Klasse werde nicht gemeldet. Das stimmt nicht —
`HomeLayoutSettings` lebt davon. Ein Kommentar, der einen falschen
Mechanismus behauptet, schickt den nächsten in die falsche Richtung;
jetzt steht der belegte Grund dort.

Dazu, damit dieser Weg überhaupt prüfbar ist: die Attrappe meldet mit
`--vlc` DTS-Ton. AVFoundation kann das nicht, also fällt die App auf
VLCKit zurück. Die Beispieldatei bleibt dieselbe — geprüft wird der Weg,
nicht der Ton. Ohne diesen Schalter ist der VLC-Zweig im Simulator gar
nicht zu erreichen, und genau deshalb lagen beide Fehler so lange da.

Ebenfalls dabei: `EdgeInsets.symmetrical` mit fünf Tests und das Polster
im Player, das damit nicht mehr verschiebt statt einrückt.

iOS und tvOS bauen, 465 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 21:05:57 +02:00

263 lines
12 KiB
Python

"""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, os, struct, zlib, hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
USER_ID = "user-1"
TOKEN = "stub-token"
SAMPLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sample.mp4")
# Was an Wiedergabemeldungen hereinkam — damit sich prüfen lässt, ob die App
# den Fortschritt tatsächlich meldet.
REPORTS = []
# Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
# auf Umrechnen, und der Weg über PlaybackInfo lässt sich prüfen.
UNANALYSED = "--unanalysed" in os.sys.argv
# Mit `--vlc` melden die Quellen DTS-Ton. AVFoundation kann das nicht, also
# faellt die App auf VLCKit zurueck — und dieser Weg ist sonst mit der
# Beispieldatei gar nicht erreichbar. Die Datei selbst bleibt dieselbe; VLC
# spielt sie ebenso, und geprueft wird der Weg, nicht der Ton.
AS_VLC = "--vlc" in os.sys.argv
AUDIO_CODEC = "dts" if AS_VLC else "aac"
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},
"MediaSources": [{
"Id": "src-" + key,
"Container": "mp4",
"Size": 28135,
"Bitrate": 1_200_000,
"RunTimeTicks": 10 * 10_000_000,
"SupportsDirectPlay": True,
"SupportsDirectStream": True,
"SupportsTranscoding": True,
"MediaStreams": [] if UNANALYSED else [
# Die Datei selbst ist 640x360; hier steht 1080p, damit die
# App die kleineren Download-Stufen überhaupt anbietet. Für die
# Oberfläche zählt, was der Server meldet.
{"Index": 0, "Type": "Video", "Codec": "h264",
"Width": 1920, "Height": 1080},
{"Index": 1, "Type": "Audio", "Codec": AUDIO_CODEC, "Channels": 2,
"Language": "ger", "DisplayTitle": "Deutsch", "IsDefault": True},
],
}],
}
if resume:
# Drei Sekunden, absolut: die Beispieldatei ist zehn Sekunden lang.
# Ein Anteil der angegebenen Laufzeit läge weit dahinter, und der
# Player stünde am Ende vor einem schwarzen Bild — was wie ein Fehler
# der App aussieht und keiner ist.
d["UserData"]["PlaybackPositionTicks"] = 3 * 10_000_000
if series:
d.update({"SeriesId": series[0], "SeriesName": series[1],
"ParentIndexNumber": 1, "IndexNumber": series[2],
"SeriesPrimaryImageTag": "p-" + series[0]})
return d
# Wie viele erfundene Titel die Mediathek zusätzlich führt.
#
# Zum Prüfen des Nachladens: mit einer Handvoll Titeln passt alles auf eine
# Seite, und ob überhaupt eine zweite angefordert wird, sieht man nie.
FUELLUNG = int(os.environ.get("FLIXY_STUB_LIBRARY", "0"))
def fuellmaterial(kind):
return [
item(f"fill-{kind}-{n}", f"Titel {n:03d}", kind, "Erfunden, zum Blättern.")
for n in range(1, FUELLUNG + 1)
]
# Was an Seiten angefragt wurde — die eigentliche Messung.
PAGE_LOG = []
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()
# Kräftig statt gedeckt: an blassen Flächen lässt sich nicht erkennen, ob
# ein Bild tatsächlich hinter der Seitenleiste weiterläuft.
return (60 + h[0] % 190, 40 + h[1] % 150, 60 + h[2] % 190)
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"},
# Ohne `CollectionType` — so meldet Jellyfin eine Mediathek
# mit gemischtem Inhalt. Genau die ist vorher wortlos
# herausgefallen.
{"Id": "lib-mixed", "Name": "Gemischtes", "Type": "CollectionFolder"},
]))
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)),
]))
# Sowohl `/stream` (abspielen) als auch `/stream.mp4` (herunterladen).
# Umgerechnet wird hier natürlich nichts — es kommt dieselbe Datei.
if p.startswith("/Videos/") and ("/stream" in p):
return self.serve_file(SAMPLE, "video/mp4")
if p.startswith(f"/Users/{USER_ID}/Items/") and p.count("/") == 4:
key = p.rsplit("/", 1)[-1]
match = next((t for t in TITLES if t[0] == key), None)
if match:
return self.reply(item(*match))
return self.reply(item(key, "Unbekannt", "Movie", "Aus dem Nichts."))
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
gesamt = [item(*t) for t in chosen] + fuellmaterial(kind)
# **Blättern wie der echte Server.** Vorher kam immer alles auf
# einmal und `TotalRecordCount` war die Länge der Antwort — damit
# sah eine App, die richtig blättert, genauso aus wie eine, die
# es gar nicht versucht. Jetzt steht in der Antwort, wie viele es
# insgesamt gibt, und geliefert wird nur der verlangte Ausschnitt.
start = int(q.get("StartIndex", ["0"])[0])
grenze = int(q.get("Limit", [str(len(gesamt))])[0])
ausschnitt = gesamt[start:start + grenze]
PAGE_LOG.append({"start": start, "limit": grenze,
"geliefert": len(ausschnitt), "gesamt": len(gesamt)})
return self.reply({"Items": ausschnitt,
"TotalRecordCount": len(gesamt),
"StartIndex": start})
if p == "/__pages":
return self.reply(PAGE_LOG)
if p == "/__reports":
return self.reply(REPORTS)
if p == "/Genres":
return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}]))
return self.reply(page([]))
def serve_file(self, path, content_type):
"""Mit Bereichsanfragen — AVPlayer holt eine Datei nie am Stück."""
with open(path, "rb") as handle:
data = handle.read()
rng = self.headers.get("Range")
start, end = 0, len(data) - 1
status = 200
if rng and rng.startswith("bytes="):
piece = rng[6:].split("-")
start = int(piece[0] or 0)
if len(piece) > 1 and piece[1]:
end = min(int(piece[1]), len(data) - 1)
status = 206
body = data[start:end + 1]
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(len(body)))
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
self.end_headers()
self.wfile.write(body)
def do_DELETE(self):
REPORTS.append(("encoding-stopped", self.path))
self.reply({})
def do_POST(self):
url = urlparse(self.path)
length = int(self.headers.get("Content-Length") or 0)
body = 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.startswith("/Sessions/Playing"):
REPORTS.append((url.path, body.decode() if body else ""))
return self.reply({})
if url.path.startswith("/Items/") and url.path.endswith("/PlaybackInfo"):
key = url.path.split("/")[2]
# Keine echte Umrechnung — die Adresse zeigt auf dieselbe Datei.
# Geprüft wird hier der Weg, nicht der Transcoder.
return self.reply({
"MediaSources": [{
"Id": "src-" + key,
"Container": "mp4",
"SupportsTranscoding": True,
"TranscodingUrl": f"/Videos/{key}/stream?static=true&ApiKey={TOKEN}",
}],
"PlaySessionId": "ps-" + key,
})
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()