Du hattest recht mit der Erwartung, nur nicht mit dem Stand: entfernt war er nicht. Was wir gemacht haben, war, ihm Arbeit wegzunehmen — DTS, TrueHD und Vorbis gehen seit Kurzem über das Umpacken beim Server. Übrig blieb er an einer Stelle, `PlaybackDecider.fallback`. Jetzt ist er ganz weg, samt dem 920-MB-Binärpaket. **Was an seine Stelle tritt.** Die Entscheidung sagt nicht mehr, *wer* spielt, sondern *was ankommt*: direkt, umgepackt oder umgerechnet. `PlaybackEngineKind` fällt damit weg, `directPlay` trägt keine Engine mehr, und `PlaybackPolicy` kennt kein `allowsFallbackEngine`. **Das Auffangnetz bleibt, nur anders geknüpft.** Scheiterte das Umpacken am Player, sprang bisher VLC ein — der Server entscheidet erst beim Ausliefern, ob er die Ströme wirklich kopieren kann, und manche Antworten nimmt AVPlayer nicht an. Statt der zweiten Engine rechnet der Server jetzt neu (`fallBackToTranscode`). Teurer für ihn, aber es kommt etwas an, statt einer schwarzen Wand mit einer Fehlernummer. **Offline wäre das ein Rückschritt gewesen** — dort gibt es keinen Server, der einspringt. Deshalb ging der vorige Commit voraus: was heruntergeladen wird, kommt schon in einer Hülle, die der Systemplayer öffnet. Was dadurch überall gilt, auch für Dateien, die bisher bei VLC landeten: Bild-in-Bild, AirPlay, Bedienung im Sperrbildschirm — und dasselbe Verhalten auf dem Apple TV, wo es VLC ohnehin nie gab. Die Attrappe heißt ihren Schalter jetzt `--dts` statt `--vlc`: der Fall ist geblieben, sein Ziel nicht. iOS und tvOS bauen, 494 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
491 lines
23 KiB
Python
491 lines
23 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, time, 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 kuenstliche grosse Datei — und was mit ihr passieren soll.
|
|
#
|
|
# **Ohne sie ist ein abgebrochener Download nicht nachstellbar**, und genau
|
|
# daran ist ein Fehler jahrelang vorbeigekommen: eine bei sechzig Prozent
|
|
# abgerissene Datei galt als fertig. Mit der Beispieldatei (28 KB) laesst sich
|
|
# das nicht zeigen — sie ist in einem Rutsch da.
|
|
#
|
|
# groesse wie gross die Datei sich gibt (Content-Length)
|
|
# abbruch_bei nach wie vielen Bytes die Verbindung geschlossen wird, 0 = nie
|
|
# tempo Bytes je Sekunde, 0 = so schnell die Leitung hergibt
|
|
# anfragen jede Anfrage mit ihrem Bereich — daran ist ablesbar, ob
|
|
# fortgesetzt oder von vorn begonnen wurde
|
|
# ohne_laenge keine Content-Length ankuendigen. Dann gilt fuer den Client
|
|
# das Ende der Verbindung als Ende der Datei — eine abgerissene
|
|
# Uebertragung sieht damit wie eine vollstaendige aus. Genau
|
|
# dieser Fall macht Dateien kaputt, ohne dass irgendwo ein
|
|
# Fehler auftaucht.
|
|
TRANSFER = {"groesse": 20_000_000, "abbruch_bei": 0, "tempo": 0,
|
|
"ohne_laenge": 0, "anfragen": []}
|
|
# Dieselbe Drossel fuer die Beispieldatei. Damit laesst sich der Player lange
|
|
# genug offen halten, um eine Geste zu messen.
|
|
VIDEO_TEMPO = {"bytes_je_sekunde": 0}
|
|
# 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 `--dts` melden die Quellen DTS-Ton. AVFoundation kann das nicht, also
|
|
# muss der Server umpacken — und dieser Weg ist sonst mit der Beispieldatei
|
|
# gar nicht erreichbar. Die Datei selbst bleibt dieselbe; geprueft wird die
|
|
# Entscheidung, nicht der Ton.
|
|
#
|
|
# Hiess einmal `--vlc`, weil dieser Fall damals bei der zweiten Engine landete.
|
|
# Die gibt es nicht mehr.
|
|
AS_DTS = "--dts" in os.sys.argv
|
|
AUDIO_CODEC = "dts" if AS_DTS 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."),
|
|
# Die kuenstliche grosse Datei. Nicht zum Abspielen — darin stehen Nullen.
|
|
("big1", "Die grosse Datei", "Movie", "Zum Pruefen von Abbruch und Fortsetzen."),
|
|
]
|
|
# 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",
|
|
# Die angekuendigte Groesse. Sie landet als `expectedBytes` im
|
|
# Download und entscheidet mit darueber, ob eine Datei als
|
|
# abgerissen gilt.
|
|
"Size": TRANSFER["groesse"] if key == "big1" else 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):
|
|
if p.split("/")[2] == "big1":
|
|
return self.serve_transfer()
|
|
return self.serve_file(SAMPLE, "video/mp4")
|
|
if p == "/__transfer":
|
|
return self.reply(TRANSFER)
|
|
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_transfer(self):
|
|
"""Eine beliebig grosse Datei, die auf Wunsch mittendrin abreisst.
|
|
|
|
Sie besteht aus Nullen — geprueft wird die Uebertragung, nicht der
|
|
Inhalt. Bereichsanfragen beantwortet sie wie ein echter Server, und
|
|
genau daran ist ablesbar, ob die App fortsetzt oder von vorn beginnt:
|
|
steht im Mitschnitt `bytes=12000000-`, hat sie fortgesetzt.
|
|
"""
|
|
gesamt = TRANSFER["groesse"]
|
|
bereich = self.headers.get("Range")
|
|
start, status = 0, 200
|
|
if bereich and bereich.startswith("bytes="):
|
|
start = int(bereich[6:].split("-")[0] or 0)
|
|
status = 206
|
|
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "video/mp4")
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
# **Ohne Pruefmarke kein Fortsetzen.** `URLSession` legt dem
|
|
# Wiederaufnahme-Versuch ein `If-Range` bei; fehlt dem Server eine
|
|
# Marke, mit der er das beantworten koennte, faengt die Uebertragung
|
|
# wieder bei null an — ganz gleich, wie gut die App ihren Datensatz
|
|
# aufbewahrt hat. Gemessen: ohne diese zwei Zeilen kam die zweite
|
|
# Anfrage ohne `Range` und holte alle 20 MB noch einmal.
|
|
self.send_header("ETag", f'"big1-{TRANSFER["groesse"]}"')
|
|
self.send_header("Last-Modified", "Mon, 01 Sep 2025 12:00:00 GMT")
|
|
if TRANSFER["ohne_laenge"]:
|
|
# Ohne Laenge und ohne Keep-Alive: das Ende der Verbindung ist das
|
|
# Ende der Datei. So verhaelt sich ein Transcoder, der die Endgroesse
|
|
# vorab nicht kennt.
|
|
self.protocol_version = "HTTP/1.0"
|
|
self.send_header("Connection", "close")
|
|
self.close_connection = True
|
|
else:
|
|
self.send_header("Content-Length", str(gesamt - start))
|
|
if status == 206:
|
|
self.send_header("Content-Range", f"bytes {start}-{gesamt - 1}/{gesamt}")
|
|
self.end_headers()
|
|
|
|
grenze = TRANSFER["abbruch_bei"]
|
|
tempo = TRANSFER["tempo"]
|
|
block = bytes(64 * 1024)
|
|
stelle, geliefert = start, 0
|
|
while stelle < gesamt:
|
|
if grenze and stelle >= grenze:
|
|
break
|
|
rest = gesamt - stelle
|
|
if grenze:
|
|
rest = min(rest, grenze - stelle)
|
|
menge = min(len(block), rest)
|
|
try:
|
|
self.wfile.write(block[:menge])
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
break
|
|
stelle += menge
|
|
geliefert += menge
|
|
if tempo:
|
|
time.sleep(menge / tempo)
|
|
|
|
TRANSFER["anfragen"].append(
|
|
{"bereich": bereich, "ab": start, "geliefert": geliefert,
|
|
"abgerissen": bool(grenze) and stelle < gesamt}
|
|
)
|
|
# Angekuendigt war mehr, als gekommen ist — die Verbindung muss zu.
|
|
if stelle < gesamt:
|
|
self.close_connection = True
|
|
|
|
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()
|
|
|
|
tempo = VIDEO_TEMPO["bytes_je_sekunde"]
|
|
if not tempo:
|
|
return self.wfile.write(body)
|
|
# Gedrosselt: nur so bleibt der Player lange genug offen, um eine
|
|
# Geste daran zu messen. Die Beispieldatei ist sonst sofort da.
|
|
for stelle in range(0, len(body), 4096):
|
|
try:
|
|
self.wfile.write(body[stelle:stelle + 4096])
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
return
|
|
time.sleep(4096 / tempo)
|
|
|
|
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({})
|
|
# Die Uebertragung steuern: Groesse, Abbruchstelle, Tempo — und die
|
|
# Drossel fuer die Beispieldatei. Alles zur Laufzeit, damit sich
|
|
# „abreissen lassen, dann fortsetzen" in einem Lauf pruefen laesst.
|
|
if url.path == "/__transfer":
|
|
neu = json.loads(body or b"{}")
|
|
for feld in ("groesse", "abbruch_bei", "tempo", "ohne_laenge"):
|
|
if feld in neu:
|
|
TRANSFER[feld] = int(neu[feld])
|
|
if neu.get("vergessen"):
|
|
TRANSFER["anfragen"].clear()
|
|
if "video_tempo" in neu:
|
|
VIDEO_TEMPO["bytes_je_sekunde"] = int(neu["video_tempo"])
|
|
return self.reply(TRANSFER)
|
|
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()
|