Ein Download riss ab, galt als fertig — und setzte nie fort
Dein Bericht: bei rund 3 GB abgebrochen, beim Fortsetzen wieder bei null,
und später „plötzlich bei 60 Prozent fertig". Beide Hälften standen so im
Code.
**Fertig war sie nicht, sie war halb.**
if expected > 0, Double(written) < Double(expected) * 0.5 { verwerfen }
Alles über der Hälfte galt als vollständig. Diese Regel war nie für diesen
Fall gedacht, sondern für die **hochgerechnete** Größe der Transcoder-Stufen
(Bitrate × Dauer) — dort ist Abweichung normal. Beim Original nennt der
Server die Dateigröße in den Metadaten, und da ist jedes fehlende Byte ein
Abriss.
Das unterscheidet jetzt `expectedIsExact`, gesetzt beim Planen und
mitgeführt am Eintrag. Dazu merkt sich der Koordinator, für welche Titel der
Server eine `Content-Length` geschickt hat — auch das ist eine Zusage.
**„Fortsetzen" war eine Zusage, die der Code nicht halten konnte.**
`NSURLSessionDownloadTaskResumeData` kam im ganzen Projekt nicht vor; den
Wiederaufnahme-Datensatz gab es nur beim Pausieren von Hand. Nach einem
Netzfehler war er weg, und der Knopf daneben hieß trotzdem „Fortsetzen".
Jetzt wird er aus dem Fehler gerettet, und wo es ihn nicht gibt, heißt der
Knopf „Erneut versuchen".
**Und es merkte niemand, wenn das WLAN zurückkam.** `RootView` reicht das
jetzt weiter — nur für Einträge mit Wiederaufnahme-Datensatz, damit ein 404
sich nicht im Kreis versucht.
**Rate und Restzeit** stehen in der Zeile: „12 MB / 20 MB · 3,4 MB/s ·
gleich fertig". `DownloadRate` ist ein reiner Wertetyp mit Uhr von außen —
geglättet, weil rohe Differenzen um ein Vielfaches springen, und ohne Zahl,
solange nichts gemessen ist. Neun Tests.
**Zwei Dinge, die erst die Messung gezeigt hat** — und die beide nicht in
der App liegen:
Erstens: reißt eine Übertragung ab, **die eine `Content-Length` angekündigt
hatte**, wiederholt die Hintergrundsitzung von iOS sie selbst — stumm, ohne
den Delegaten zu fragen, und **jedes Mal ab Byte null**. Gemessen: sieben
Anläufe, jeder die vollen 12 MB. Das ist genau dein „fing wieder bei null
an", und dagegen hilft kein Wiederaufnahme-Datensatz.
Zweitens: ein Fortsetzen braucht eine Prüfmarke vom Server. Ohne `ETag`
oder `Last-Modified` kann iOS sein `If-Range` nicht stellen und lädt alles
noch einmal — gemessen, 20 MB zweimal. Mit Marke kam die zweite Anfrage als
`Range: bytes=5636096-` und holte nur den Rest.
Die Attrappe kann das jetzt alles nachstellen: eine beliebig große Datei,
die auf Wunsch mittendrin abreißt, mit oder ohne angekündigte Länge, und
gedrosselt. Ohne sie ist nichts davon beweisbar — und genau daran ist der
Fehler vorbeigekommen.
Im Simulator gegen die Attrappe: 12 von 20 MB → „Fehlgeschlagen:
Übertragung unvollständig", nicht Haken. Vollständig → Haken. iOS und tvOS
bauen, 489 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c37087fd34
commit
e86c0a6e95
@@ -1,6 +1,6 @@
|
||||
"""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
|
||||
import json, os, struct, time, zlib, hashlib
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
@@ -10,6 +10,28 @@ 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
|
||||
@@ -49,6 +71,8 @@ TITLES = [
|
||||
# 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.
|
||||
@@ -72,7 +96,10 @@ def item(key, name, kind, overview, *, resume=None, series=None):
|
||||
"MediaSources": [{
|
||||
"Id": "src-" + key,
|
||||
"Container": "mp4",
|
||||
"Size": 28135,
|
||||
# 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,
|
||||
@@ -251,7 +278,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 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)
|
||||
@@ -290,6 +321,74 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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:
|
||||
@@ -311,7 +410,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if status == 206:
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
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))
|
||||
@@ -330,6 +441,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user