„Aus Weiterschauen entfernen" tut jetzt etwas — und man kann es sehen

Der Knopf war wirkungslos, und zwar aus zwei Gründen, die beide auf
dasselbe hinauslaufen: er war nie geprüft worden.

**Der Weg war falsch.** Die Methode las erst ein Benutzerdatenblatt über
`GET /Users/{id}/Items/{id}/UserData`, änderte es und schrieb es zurück.
Diese Stelle war die **einzige im ganzen Projekt**, die ein solches Blatt
eigens liest — überall sonst kommt es am Titel mit. Es gab also kein
zweites Beispiel, an dem der Weg je funktioniert hätte, und ein 404
beendete die Sache, bevor überhaupt etwas geschrieben wurde.

Jetzt wird das Blatt **gebaut**, nicht erfragt: „gesehen", „Favorit" und
die Zählung stehen am Titel, den der Aufrufer ohnehin in der Hand hat —
deshalb nimmt `removeFromContinueWatching` jetzt den Titel statt nur
seiner Kennung. Gelesen wird trotzdem noch, aber unverbindlich: gelingt
es, bleiben auch Felder erhalten, die diese App gar nicht kennt. Und
geschrieben wird über beide Wege — erst der gebündelte
`/UserItems/{id}/UserData`, bei einem 404 der ältere. Welcher gilt, hängt
an der Serverfassung.

**Und man konnte es nicht sehen.** Die Attrappe kannte die Route nicht;
ihr Auffangzweig antwortete auf jeden Schreibversuch mit Status 200, und
die Weiterschauen-Reihe war fest verdrahtet. Der Aufruf gelang also immer
und änderte nie etwas — genau das Bild eines kaputten Knopfes, nur aus
einem anderen Grund. Genau deshalb ist der Fehler durchgerutscht.

Sie führt jetzt echte Benutzerdatenblätter, beantwortet beide Wege und
liefert unter „Weiterschauen" nur noch, was eine gemerkte Stelle grösser
null hat. Unter `/__userdata` steht, was geschrieben wurde und wohin.

Gemessen im Simulator, nach einem Tipp auf den Eintrag:

    /UserItems/m2/UserData
    {"Played":false,"IsFavorite":false,"PlaybackPositionTicks":0,…}

Danach ist „Weiterschauen" leer und die Reihe verschwindet von der
Startseite.

**Zwei Dinge, die danach trotzdem auffallen können**, und beide sind
keine Fehler: Jellyfins „Als Nächstes" führt angefangene Folgen mit, eine
entfernte Folge kann dort wieder auftauchen. Und bei zusammengelegten
Reihen stehen auch Folgen ohne gemerkte Stelle — dort gibt es nichts zu
entfernen, und der Eintrag fehlt.

Plex bleibt unberührt: der Weg dort war in Ordnung und bekommt nur die
neue Form.

iOS und tvOS bauen, 469 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-01 09:30:20 +02:00
co-authored by Claude Opus 5
parent ac62e8bf19
commit 19c9dc843c
6 changed files with 163 additions and 25 deletions
+7
View File
@@ -27,6 +27,13 @@ Wiedergabemeldung mit ihrem Anfragekörper. Damit lässt sich prüfen, ob die Ap
den Beginn meldet, ob der Fortschritt in Ticks ankommt und ob am Ende
abgeräumt wird.
Die Benutzerdaten sind echt: `/UserItems/{id}/UserData` und der ältere Weg
`/Users/{id}/Items/{id}/UserData` lesen und schreiben ein Blatt je Titel, und
„Weiterschauen" führt nur, was eine gemerkte Stelle grösser null hat. Unter
`http://127.0.0.1:8096/__userdata` steht, was geschrieben wurde und über
welchen der beiden Pfade — daran ist ablesbar, ob „Aus Weiterschauen
entfernen" wirklich etwas tut.
Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
wie bei einem Server, der eine Datei noch nicht untersucht hat.
+68 -6
View File
@@ -10,6 +10,21 @@ 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
@@ -60,12 +75,16 @@ def item(key, name, kind, overview, *, resume=None, series=None):
],
}],
}
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.
# 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],
@@ -87,6 +106,21 @@ def fuellmaterial(kind):
# 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}
@@ -161,10 +195,17 @@ class Handler(BaseHTTPRequestHandler):
gewaehlt = [t for t in TITLES if erlaubt is None or t[2] in erlaubt]
return self.reply([item(*t) for t in gewaehlt[:6]])
if p == f"/Users/{USER_ID}/Items/Resume":
return self.reply(page([
# 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.
@@ -177,6 +218,12 @@ class Handler(BaseHTTPRequestHandler):
{"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.",
@@ -215,6 +262,9 @@ class Handler(BaseHTTPRequestHandler):
"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":
@@ -261,6 +311,18 @@ class Handler(BaseHTTPRequestHandler):
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.