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>
309 lines
12 KiB
Swift
309 lines
12 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Plex als `MediaBackend`.
|
|
///
|
|
/// Eine Hülle um `PlexServer`, keine Umschreibung: die Endpunkte bleiben, wo
|
|
/// sie sind, und werden hier nur in die neutrale Form gebracht. Das ist der
|
|
/// ganze Zweck — die Oberfläche fragt nach `MediaItem`, und was dafür über die
|
|
/// Leitung geht, ist ihre Sache nicht.
|
|
public struct PlexBackend: MediaBackend {
|
|
/// Der darunterliegende Server.
|
|
///
|
|
/// Noch öffentlich, weil Player, Downloads und Live-TV vorerst direkt mit
|
|
/// ihm sprechen — diese drei Bereiche wandern als Nächstes.
|
|
public let server: PlexServer
|
|
|
|
/// Live-TV mit Gedächtnis.
|
|
///
|
|
/// Ein Actor, also ein Referenztyp: Kopien dieses Struct teilen sich
|
|
/// denselben und damit die geladene Senderliste. Warum das nötig ist,
|
|
/// steht bei `PlexLiveTVSession`.
|
|
let live: PlexLiveTVSession
|
|
|
|
public init(server: PlexServer) {
|
|
self.server = server
|
|
self.live = PlexLiveTVSession(server: server)
|
|
}
|
|
|
|
public nonisolated var serverName: String { server.name }
|
|
|
|
/// Die Maschinenkennung — sie überlebt Umbenennen und Adresswechsel,
|
|
/// genau dafür gibt es sie.
|
|
public nonisolated var identity: String { "plex:\(server.resource.clientIdentifier)" }
|
|
|
|
public nonisolated var capabilities: Set<BackendCapability> {
|
|
// Keine Favoriten: Plex kennt sie nicht, und ein Knopf, der nichts
|
|
// bewirkt, ist schlimmer als keiner.
|
|
[.liveTV, .ratings, .libraryScan, .transcoding]
|
|
}
|
|
|
|
// MARK: - Blättern
|
|
|
|
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
|
|
try await mapping { try await server.libraries().map(\.asMediaLibrary) }
|
|
}
|
|
|
|
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
|
try await mapping { try await server.homeHubs().map(\.asMediaRowGroup) }
|
|
}
|
|
|
|
public func items(
|
|
inLibrary library: MediaLibrary,
|
|
query: MediaQuery,
|
|
offset: Int,
|
|
limit: Int
|
|
) async throws(MediaBackendError) -> MediaPage {
|
|
try await mapping {
|
|
let page = try await server.items(
|
|
inLibrary: plexLibrary(library),
|
|
query: LibraryQuery(query),
|
|
offset: offset,
|
|
limit: limit
|
|
)
|
|
return MediaPage(
|
|
items: page.items.map(\.asMediaItem),
|
|
offset: page.offset,
|
|
totalCount: page.totalSize
|
|
)
|
|
}
|
|
}
|
|
|
|
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
|
|
try await mapping { try await server.metadata(ratingKey: id).asMediaItem }
|
|
}
|
|
|
|
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
|
|
try await mapping { try await server.children(of: id).map(\.asMediaItem) }
|
|
}
|
|
|
|
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
|
|
try await mapping { try await server.search(text, limit: limit).map(\.asMediaRowGroup) }
|
|
}
|
|
|
|
public func filterOptions(
|
|
inLibrary library: MediaLibrary,
|
|
field: MediaQuery.Field
|
|
) async throws(MediaBackendError) -> [MediaFilterOption] {
|
|
try await mapping {
|
|
try await server
|
|
.filterOptions(inLibrary: plexLibrary(library), field: field.rawValue)
|
|
.map { MediaFilterOption(id: $0.key, title: $0.title) }
|
|
}
|
|
}
|
|
|
|
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
|
try await mapping {
|
|
// Der Weg führt über die Geschwister; dafür braucht es den
|
|
// vollständigen Plex-Eintrag, nicht nur die Kennung.
|
|
let plex = try await server.metadata(ratingKey: item.id)
|
|
return try await server.nextEpisode(after: plex)?.asMediaItem
|
|
}
|
|
}
|
|
|
|
// MARK: - Bilder
|
|
|
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
|
server.artworkURL(path: path, width: width, height: height)
|
|
}
|
|
|
|
public nonisolated func rawImageURL(path: String?) -> URL? {
|
|
server.rawImageURL(path: path)
|
|
}
|
|
|
|
// MARK: - Herunterladen
|
|
|
|
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
|
|
guard let key = file.key else { return nil }
|
|
// Nur der Schlüssel wird gebraucht; alles andere steckt schon in der
|
|
// Adresse oder ist für den Abruf ohne Belang.
|
|
return server.downloadURL(part: PlexPart(id: 0, key: key))
|
|
}
|
|
|
|
public nonisolated func compressedDownloadURL(
|
|
itemID: String,
|
|
versionIndex: Int,
|
|
maximumBitrate: Int,
|
|
resolution: String?
|
|
) -> URL? {
|
|
server.transcodedDownloadURL(
|
|
ratingKey: itemID,
|
|
mediaIndex: versionIndex,
|
|
maximumBitrate: maximumBitrate,
|
|
resolution: resolution
|
|
)
|
|
}
|
|
|
|
// MARK: - Abspielen
|
|
|
|
public func preparePlayback(
|
|
_ intent: PlaybackIntent
|
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
|
guard let transcode = intent.transcode else {
|
|
// Unverändert übertragen: die Datei selbst, ohne Sitzung — es gibt
|
|
// nichts anzuhalten, was der Server angelegt hätte.
|
|
guard let key = intent.file.key,
|
|
let url = server.directPlayURL(part: PlexPart(id: 0, key: key))
|
|
else { throw .notFound }
|
|
return PlaybackTarget(url: url)
|
|
}
|
|
|
|
let request = TranscodeRequest(
|
|
ratingKey: intent.itemID,
|
|
mediaIndex: intent.versionIndex,
|
|
maximumBitrate: transcode.maximumBitrate,
|
|
resolution: transcode.resolution,
|
|
isLocal: intent.isLocalConnection
|
|
)
|
|
|
|
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier schon
|
|
// nein, steht der Grund im Klartext da — statt eines schwarzen Bildes
|
|
// nach zwanzig Sekunden Warten.
|
|
//
|
|
// **Ohne `autoAdjustQuality`**, und das mit Absicht: der Entscheider
|
|
// antwortet auf Unbekanntes mit nackten 400ern — dafür gibt es in
|
|
// diesem Paket eigens `transcoderBisect`. Er beantwortet ohnehin nur
|
|
// „darf das gespielt werden", und daran ändert eine Leiter nichts.
|
|
// Stünde der Wert hier und der Server nähme ihn übel, wäre nicht die
|
|
// Leiter kaputt, sondern die Wiedergabe.
|
|
let decision = try await mapping { try await server.transcodeDecision(for: request) }
|
|
guard decision.isAllowed else {
|
|
throw .refused(reason: decision.explanation ?? "")
|
|
}
|
|
|
|
// **`autoAdjustQuality` wird nicht geschickt — gemessen, nicht vermutet.**
|
|
//
|
|
// Die Zeile stand hier und ist wieder raus. Gegen den Testserver
|
|
// beantwortet Plex dieselbe Anfrage mit dem Wert mit einer nackten
|
|
// **HTTP 400**, ohne ihn mit einer Playlist. Zwei Anfragen, getrennte
|
|
// Sitzungskennungen, sonst identisch — der Wert ist die einzige
|
|
// Abweichung.
|
|
//
|
|
// Damit hätte er nicht die Qualität verbessert, sondern die
|
|
// Wiedergabe verhindert. Plex' eigene Clients schicken ihn; welche
|
|
// weitere Angabe sie dabei mitgeben, ist offen. Bis das gemessen ist,
|
|
// bleibt er draußen: eine Leiter, die es vielleicht gibt, ist keinen
|
|
// Stream wert, der sicher nicht läuft.
|
|
//
|
|
// `TranscodeRequest.adaptive` bleibt samt Tests bestehen — das
|
|
// Gerüst steht, es fehlt die richtige Beschwörungsformel.
|
|
guard let url = server.transcodeURL(for: request) else { throw .notFound }
|
|
return PlaybackTarget(url: url, sessionID: request.sessionIdentifier)
|
|
}
|
|
|
|
public func endPlayback(sessionID: String) async {
|
|
await server.stopTranscode(sessionIdentifier: sessionID)
|
|
}
|
|
|
|
public func reportProgress(_ report: PlaybackReport) async {
|
|
await server.reportProgress(
|
|
ratingKey: report.itemID,
|
|
state: report.state,
|
|
time: report.time,
|
|
duration: report.duration
|
|
)
|
|
}
|
|
|
|
// MARK: - Zustand melden
|
|
|
|
public func markWatched(id: String) async throws(MediaBackendError) {
|
|
try await mapping { try await server.markWatched(ratingKey: id) }
|
|
}
|
|
|
|
public func markUnwatched(id: String) async throws(MediaBackendError) {
|
|
try await mapping { try await server.markUnwatched(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) {
|
|
// Plex kennt keine Favoriten. Stillschweigend nichts zu tun wäre
|
|
// schlimmer als zu sagen, dass es das hier nicht gibt.
|
|
throw .notFound
|
|
}
|
|
|
|
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
|
|
try await mapping { try await server.rate(ratingKey: id, rating: rating) }
|
|
}
|
|
|
|
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
|
|
try await mapping { try await server.refreshLibrary(plexLibrary(library)) }
|
|
}
|
|
|
|
// MARK: - Hilfen
|
|
|
|
/// Baut aus der neutralen Mediathek die Plex-Form zurück.
|
|
///
|
|
/// Nur `key` und `type` werden gebraucht; alles andere hat der Server
|
|
/// ohnehin. Ein zweiter Abruf, nur um ein Modell vollständig zu machen,
|
|
/// das gleich wieder zu einer Adresse wird, wäre verschwendet.
|
|
private func plexLibrary(_ library: MediaLibrary) -> PlexLibrary {
|
|
let type: String = switch library.kind {
|
|
case .movie: "movie"
|
|
case .show: "show"
|
|
case .music: "artist"
|
|
case .photo: "photo"
|
|
case .liveTV: "livetv"
|
|
case .other: "unknown"
|
|
}
|
|
return PlexLibrary(key: library.id, title: library.title, type: type)
|
|
}
|
|
|
|
/// Führt einen Plex-Aufruf aus und übersetzt seinen Fehler.
|
|
///
|
|
/// Der Block wirft ungetypt, weil Swift den getippten Wurf durch eine
|
|
/// Closure hindurch nicht ableiten kann. Der zweite `catch` ist deshalb
|
|
/// nicht Zierde: gäbe es ihn nicht, fiele ein unerwarteter Fehler durch
|
|
/// und die Signatur wäre eine Lüge.
|
|
private func mapping<Value>(
|
|
_ work: () async throws -> Value
|
|
) async throws(MediaBackendError) -> Value {
|
|
do {
|
|
return try await work()
|
|
} catch let error as PlexError {
|
|
throw error.asMediaBackendError
|
|
} catch {
|
|
throw .transport(description: String(describing: error))
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PlexError {
|
|
public var asMediaBackendError: MediaBackendError {
|
|
switch self {
|
|
case .unauthorized: .unauthorized
|
|
case .notFound: .notFound
|
|
case .noReachableConnection: .noReachableConnection
|
|
case .server(let status): .server(status: status)
|
|
case .transport(let description): .transport(description: description)
|
|
case .decoding(let description): .decoding(description: description)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension LibraryQuery {
|
|
/// Übersetzt die neutrale Abfrage in Plex' Schreibweise.
|
|
init(_ query: MediaQuery) {
|
|
let sort: Sort = switch query.sort {
|
|
case .titleAscending: .titleAscending
|
|
case .titleDescending: .titleDescending
|
|
case .recentlyAdded: .recentlyAdded
|
|
case .releaseDate: .releaseDate
|
|
case .rating: .rating
|
|
case .lastPlayed: .lastViewed
|
|
}
|
|
|
|
self.init(
|
|
sort: sort,
|
|
unwatchedOnly: query.unwatchedOnly,
|
|
genre: query.genre,
|
|
year: query.year,
|
|
contentRating: query.contentRating,
|
|
resolution: query.resolution
|
|
)
|
|
}
|
|
}
|