diff --git a/Flixy/Views/Components/ItemActionEntries.swift b/Flixy/Views/Components/ItemActionEntries.swift index caecad7..5b43238 100644 --- a/Flixy/Views/Components/ItemActionEntries.swift +++ b/Flixy/Views/Components/ItemActionEntries.swift @@ -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") } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 5cdb090..546cc46 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -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) { diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index fe8781f..26a82dc 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -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) diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift index 650b306..8976a2d 100644 --- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -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) { diff --git a/Tools/JellyfinStub/README.md b/Tools/JellyfinStub/README.md index 9e39aad..552f424 100644 --- a/Tools/JellyfinStub/README.md +++ b/Tools/JellyfinStub/README.md @@ -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. diff --git a/Tools/JellyfinStub/server.py b/Tools/JellyfinStub/server.py index 578e135..7c9082f 100644 --- a/Tools/JellyfinStub/server.py +++ b/Tools/JellyfinStub/server.py @@ -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.