„Werner Beinhart" über dem Bild von John Wick. Von mir eingebaut, im
Commit, der die Überblendung umgestellt hat.
**Der Grund ist der Umbau selbst.** Vorher bekam der Aufmacher je Titel
eine neue Identität; sein Zustand begann damit jedes Mal von vorn. Seit
er bleibt und nur seine Angaben wechseln, überlebt auch der geladene
Schriftzug den Wechsel — und `availableLogo` gab ihn heraus, ohne zu
prüfen, ob er überhaupt zum aktuellen Titel gehört:
if let logo { return logo } // ← zu irgendeinem Film
Jetzt trägt der Zustand die Adresse mit, zu der er gehört, und wird nur
herausgegeben, wenn sie zur aktuellen passt. Passt sie nicht, gilt der
Zwischenspeicher — und wenn auch der nichts hat, der gesetzte Titel. Nie
der falsche Schriftzug.
**Dieselbe Falle steckte in `FlixyArtwork`**, nur unauffälliger: auch
dort hielt `loaded` ein Bild ohne Angabe, wozu es gehört, und `load()`
brach deswegen früh ab („ist ja schon eins da"). Solange jede Kachel ihre
eigene Identität hat, fällt das nicht auf. Der Aufmacher hat seit dem
Umbau keine mehr.
Die Regel dahinter gilt allgemein: **ein Zustand, der etwas
zwischenspeichert, muss sagen, wozu es gehört** — sonst gilt er für
alles.
Die Attrappe liefert jetzt Schriftzüge (`Logo`-Bildmarke). Ohne sie fällt
der Aufmacher auf den gesetzten Titel zurück, und dieser ganze Weg wäre
im Simulator gar nicht zu sehen — genau deshalb ist der Fehler erst beim
Nutzer aufgefallen.
iOS und tvOS bauen, 476 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
17 KiB
Python
364 lines
17 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 = []
|
|
# Die Benutzerdatenblaetter, je Titel.
|
|
#
|
|
# Ohne sie laesst sich „Aus Weiterschauen entfernen" gar nicht pruefen: der
|
|
# Auffangzweig antwortete auf jeden Schreibversuch mit 200, und die
|
|
# Weiterschauen-Reihe war fest verdrahtet. Der Aufruf gelang also immer und
|
|
# aenderte nie etwas — genau das Bild eines kaputten Knopfes.
|
|
USERDATA = {
|
|
"e1": {"PlaybackPositionTicks": 3 * 10_000_000, "Played": False,
|
|
"IsFavorite": False, "PlayCount": 0},
|
|
"m2": {"PlaybackPositionTicks": 3 * 10_000_000, "Played": False,
|
|
"IsFavorite": False, "PlayCount": 0},
|
|
}
|
|
# Jeder Schreibzugriff auf ein Blatt, mit Pfad — damit ablesbar ist, welchen
|
|
# der beiden Wege die App genommen hat.
|
|
USERDATA_LOG = []
|
|
# 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."),
|
|
# Nur in „Gemischtes". Ohne einen Titel, den es ausschliesslich dort
|
|
# gibt, laesst sich gar nicht pruefen, ob eine abgewaehlte Mediathek
|
|
# wirklich aus dem Aufmacher verschwindet: steht ueberall dasselbe,
|
|
# sieht ein wirkungsloser Filter wie ein wirksamer aus.
|
|
("x1", "Zwischen den Zeilen", "Movie", "Nur in Gemischtes zu finden."),
|
|
("x2", "Randnotiz", "Movie", "Ebenfalls nur in Gemischtes."),
|
|
]
|
|
# Welche Titel in welcher Mediathek liegen. Was hier nicht steht, liegt
|
|
# ueberall — so war es vorher, und fuer die uebrigen Pruefungen reicht das.
|
|
NUR_IN = {"x1": "lib-mixed", "x2": "lib-mixed"}
|
|
|
|
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",
|
|
# `Logo` ist der freigestellte Schriftzug. Er steht hier, damit sich
|
|
# pruefen laesst, dass beim Wechsel im Aufmacher nicht der Schriftzug
|
|
# des vorigen Titels ueber dem neuen Bild stehenbleibt — ohne Logo
|
|
# faellt der Aufmacher auf den gesetzten Titel zurueck, und dieser
|
|
# Weg waere gar nicht zu sehen.
|
|
"ImageTags": {"Primary": "p-" + key, "Logo": "l-" + 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},
|
|
],
|
|
}],
|
|
}
|
|
# Drei Sekunden, absolut: die Beispieldatei ist zehn Sekunden lang. Ein
|
|
# Anteil der angegebenen Laufzeit laege weit dahinter, und der Player
|
|
# stuende am Ende vor einem schwarzen Bild — was wie ein Fehler der App
|
|
# aussieht und keiner ist.
|
|
#
|
|
# Das Blatt gewinnt gegen das Argument: was geschrieben wurde, gilt.
|
|
if resume and key not in USERDATA:
|
|
d["UserData"]["PlaybackPositionTicks"] = 3 * 10_000_000
|
|
if key in USERDATA:
|
|
d["UserData"].update(USERDATA[key])
|
|
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 userdata_key(path):
|
|
"""Die Titelkennung, wenn der Pfad auf ein Benutzerdatenblatt zeigt.
|
|
|
|
Jellyfin hat die verstreuten Aufrufe in `/UserItems/{id}/UserData`
|
|
gebuendelt; aeltere Fassungen kennen nur den langen Weg. Die Attrappe
|
|
beantwortet beide, damit sich pruefen laesst, dass die App auf einen
|
|
404 hin den zweiten nimmt."""
|
|
teile = path.strip("/").split("/")
|
|
if len(teile) == 3 and teile[0] == "UserItems" and teile[2] == "UserData":
|
|
return teile[1]
|
|
if (len(teile) == 5 and teile[0] == "Users" and teile[2] == "Items"
|
|
and teile[4] == "UserData"):
|
|
return teile[3]
|
|
return None
|
|
|
|
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":
|
|
# `parentId` wird beachtet, sonst stuende in jeder Mediathek
|
|
# dasselbe — und die Auswahl der Mediatheken fuer den Aufmacher
|
|
# liesse sich gar nicht pruefen.
|
|
eltern = (q.get("parentId") or q.get("ParentId") or [None])[0]
|
|
erlaubt = {
|
|
"lib-movies": {"Movie"},
|
|
"lib-shows": {"Series"},
|
|
}.get(eltern)
|
|
gewaehlt = [
|
|
t for t in TITLES
|
|
if (erlaubt is None or t[2] in erlaubt)
|
|
and NUR_IN.get(t[0], eltern) == eltern
|
|
]
|
|
gewaehlt.sort(key=lambda t: t[0] not in NUR_IN)
|
|
return self.reply([item(*t) for t in gewaehlt[:6]])
|
|
if p == f"/Users/{USER_ID}/Items/Resume":
|
|
# Wie bei Jellyfin: hier steht, was eine gemerkte Stelle groesser
|
|
# null hat. Wird sie auf null gesetzt, faellt der Titel heraus —
|
|
# und genau das soll „Aus Weiterschauen entfernen" bewirken.
|
|
kandidaten = [
|
|
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),
|
|
]
|
|
return self.reply(page([
|
|
k for k in kandidaten
|
|
if (k["UserData"].get("PlaybackPositionTicks") or 0) > 0
|
|
]))
|
|
if p.startswith("/MediaSegments/"):
|
|
# Intro und Abspann, wie ein Server mit Intro-Skipper sie meldet.
|
|
# In Hundertnanosekunden. Die Beispieldatei ist zehn Sekunden
|
|
# lang; das Intro deckt davon die ersten fuenf ab, damit der
|
|
# Knopf lange genug steht, um ihn zu sehen und zu treffen.
|
|
return self.reply(page([
|
|
{"Id": "seg-1", "ItemId": p.split("/")[2], "Type": "Intro",
|
|
"StartTicks": 0, "EndTicks": 5 * 10_000_000},
|
|
{"Id": "seg-2", "ItemId": p.split("/")[2], "Type": "Outro",
|
|
"StartTicks": 8 * 10_000_000, "EndTicks": 10 * 10_000_000},
|
|
]))
|
|
schluessel = userdata_key(p)
|
|
if schluessel is not None:
|
|
return self.reply(USERDATA.get(schluessel, {
|
|
"PlaybackPositionTicks": 0, "Played": False,
|
|
"IsFavorite": False, "PlayCount": 0,
|
|
}))
|
|
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 == "/__userdata":
|
|
# Was an Blaettern geschrieben wurde, und ueber welchen Pfad.
|
|
return self.reply({"writes": USERDATA_LOG, "state": USERDATA})
|
|
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({})
|
|
schluessel = userdata_key(url.path)
|
|
if schluessel is not None:
|
|
try:
|
|
neu = json.loads(body or b"{}")
|
|
except ValueError:
|
|
neu = {}
|
|
blatt = USERDATA.setdefault(schluessel, {})
|
|
# Nur echte Werte uebernehmen: `null` heisst bei Jellyfin „nicht
|
|
# gesetzt", nicht „loeschen".
|
|
blatt.update({k: v for k, v in neu.items() if v is not None})
|
|
USERDATA_LOG.append((url.path, body.decode() if body else ""))
|
|
return self.reply(blatt)
|
|
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()
|