„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:
co-authored by
Claude Opus 5
parent
ac62e8bf19
commit
19c9dc843c
@@ -46,7 +46,7 @@ struct ItemActionEntries: View {
|
||||
// Fehler bei sich.
|
||||
if item.watchProgress != nil {
|
||||
Button {
|
||||
run { try await $0.removeFromContinueWatching(id: item.id) }
|
||||
run { try await $0.removeFromContinueWatching(item) }
|
||||
} label: {
|
||||
Label("action.removeFromContinue", systemImage: "minus.circle")
|
||||
}
|
||||
|
||||
@@ -455,28 +455,91 @@ public func isInLocalNetwork() async -> Bool {
|
||||
/// vor; was es gibt, ist das Benutzerdatenblatt. „Weiterschauen" führt,
|
||||
/// was eine Stelle grösser null hat — also nimmt man sie weg.
|
||||
///
|
||||
/// **Erst lesen, dann schreiben.** Das Blatt trägt auch „gesehen",
|
||||
/// „Favorit" und die Bewertung. Nur ein Feld zu schicken hiesse, dem
|
||||
/// Server die übrigen ohne Wert vorzulegen — und wer seinen Favoriten
|
||||
/// verliert, weil er einen Film aus einer Liste genommen hat, sucht den
|
||||
/// Grund nie an dieser Stelle. Deshalb kommt das Blatt zurück, bekommt
|
||||
/// eine Null und geht so, wie es war, wieder hin.
|
||||
public func removeFromContinueWatching(id: String) async throws(MediaBackendError) {
|
||||
let pfad = "/Users/\(account.userID)/Items/\(id)/UserData"
|
||||
let roh = try await client.send(JellyfinRequest(path: pfad))
|
||||
|
||||
guard var blatt = (try? JSONSerialization.jsonObject(with: roh)) as? [String: Any] else {
|
||||
throw .decoding(description: "Benutzerdaten zu \(id) waren kein Objekt")
|
||||
}
|
||||
/// **Das Blatt wird gebaut, nicht zurückgelesen.** Hier stand erst ein
|
||||
/// `GET` auf denselben Pfad, und genau daran scheiterte es stumm: diese
|
||||
/// Stelle war die einzige im ganzen Projekt, die ein Benutzerdatenblatt
|
||||
/// 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.
|
||||
///
|
||||
/// Gelesen wird trotzdem, aber **unverbindlich**: gelingt es, bleiben
|
||||
/// auch Felder erhalten, die diese App gar nicht kennt — Jellyfins
|
||||
/// Daumen etwa. Gelingt es nicht, wird aus dem Titel gebaut, was wir
|
||||
/// wissen. Weggeworfen wird in keinem Fall mehr als die Stelle.
|
||||
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
|
||||
var blatt = await userData(forItem: item.id) ?? [
|
||||
"Played": item.isWatched,
|
||||
"IsFavorite": item.isFavourite,
|
||||
"PlayCount": item.playCount ?? 0,
|
||||
]
|
||||
blatt["PlaybackPositionTicks"] = 0
|
||||
// Ohne das bliebe der Anteil stehen, und manche Ansicht rechnet den
|
||||
// Balken daraus statt aus der Stelle.
|
||||
blatt["PlayedPercentage"] = NSNull()
|
||||
|
||||
guard let körper = try? JSONSerialization.data(withJSONObject: blatt) else {
|
||||
throw .decoding(description: "Benutzerdaten zu \(id) liessen sich nicht schreiben")
|
||||
throw .decoding(description: "Benutzerdaten zu \(item.id) liessen sich nicht schreiben")
|
||||
}
|
||||
try await client.send(JellyfinRequest(method: .post, path: pfad, body: körper))
|
||||
try await writeUserData(körper, forItem: item.id)
|
||||
}
|
||||
|
||||
/// Das Benutzerdatenblatt, wenn der Server es herausgibt.
|
||||
///
|
||||
/// `nil` statt eines Fehlers: ob es diesen Weg gibt, hängt an der
|
||||
/// Serverfassung, und die Antwort darauf darf die Aktion nicht
|
||||
/// aufhalten.
|
||||
private func userData(forItem id: String) async -> [String: Any]? {
|
||||
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
|
||||
guard let roh = try? await client.send(JellyfinRequest(
|
||||
path: pfad.path,
|
||||
query: pfad.query
|
||||
)) else { continue }
|
||||
if let blatt = (try? JSONSerialization.jsonObject(with: roh)) as? [String: Any],
|
||||
// Der Auffangzweig mancher Server antwortet mit einer leeren
|
||||
// Seite und Status 200. Die ist kein Blatt.
|
||||
blatt["PlaybackPositionTicks"] != nil || blatt["Played"] != nil {
|
||||
return blatt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Schreibt das Blatt zurück — über den Weg, den dieser Server kennt.
|
||||
private func writeUserData(_ body: Data, forItem id: String) async throws(MediaBackendError) {
|
||||
var letzter: MediaBackendError?
|
||||
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
|
||||
do {
|
||||
try await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: pfad.path,
|
||||
query: pfad.query,
|
||||
body: body
|
||||
))
|
||||
return
|
||||
} catch {
|
||||
letzter = error
|
||||
// Nur ein „gibt es hier nicht" rechtfertigt den zweiten
|
||||
// Versuch. Ein abgelehnter Zugriff oder ein Serverfehler
|
||||
// würde beim anderen Pfad genauso ausgehen.
|
||||
guard case .notFound = error else { throw error }
|
||||
}
|
||||
}
|
||||
throw letzter ?? .notFound
|
||||
}
|
||||
|
||||
/// Die beiden Wege zum Benutzerdatenblatt, neuerer zuerst.
|
||||
///
|
||||
/// Jellyfin hat die verstreuten Aufrufe in `/UserItems/{id}/UserData`
|
||||
/// gebündelt; ältere Fassungen kennen nur `/Users/{id}/Items/{id}/UserData`.
|
||||
/// Welcher gilt, sagt einem der Server erst, wenn man fragt.
|
||||
private static func userDataPaths(
|
||||
itemID: String,
|
||||
userID: String
|
||||
) -> [(path: String, query: [URLQueryItem])] {
|
||||
[
|
||||
("/UserItems/\(itemID)/UserData", [URLQueryItem(name: "userId", value: userID)]),
|
||||
("/Users/\(userID)/Items/\(itemID)/UserData", []),
|
||||
]
|
||||
}
|
||||
|
||||
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
|
||||
|
||||
@@ -121,7 +121,12 @@ public protocol MediaBackend: Sendable {
|
||||
/// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll
|
||||
/// nur nicht ewig weiter oben stehen. „Als gesehen markieren" wäre dafür
|
||||
/// eine Lüge, „als ungesehen" tut nichts: der Fortschritt bleibt.
|
||||
func removeFromContinueWatching(id: String) async throws(MediaBackendError)
|
||||
///
|
||||
/// **Der ganze Titel, nicht nur seine Kennung.** Jellyfin kennt dafür
|
||||
/// keinen eigenen Weg; dort wird das Benutzerdatenblatt geschrieben, und
|
||||
/// darin stehen auch „gesehen" und „Favorit". Wer sie nicht mitschickt,
|
||||
/// löscht sie — und der Aufrufer hat sie ohnehin zur Hand.
|
||||
func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError)
|
||||
func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError)
|
||||
func rate(id: String, rating: Int) async throws(MediaBackendError)
|
||||
func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError)
|
||||
|
||||
@@ -214,8 +214,9 @@ public struct PlexBackend: MediaBackend {
|
||||
try await mapping { try await server.markUnwatched(ratingKey: id) }
|
||||
}
|
||||
|
||||
public func removeFromContinueWatching(id: String) async throws(MediaBackendError) {
|
||||
try await mapping { try await server.removeFromContinueWatching(ratingKey: id) }
|
||||
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
|
||||
// Plex hat einen eigenen Weg und braucht vom Titel nur die Kennung.
|
||||
try await mapping { try await server.removeFromContinueWatching(ratingKey: item.id) }
|
||||
}
|
||||
|
||||
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user