MediaBackend: der Vertrag, den Plex und Jellyfin beide erfüllen

Ein Protokoll für das, was jeder Server können muss — blättern, Bilder
liefern, Zustand melden. Bewusst schmal: alles, was nur einer kann,
steht nicht darin, sondern als abfragbare Fähigkeit. Ein gemeinsamer
Nenner, der alles umfasst, wäre keiner mehr.

Deshalb `BackendCapability`. Plex kennt keine Favoriten, Jellyfin keine
Plex-Empfänger — und eine Oberfläche, die Knöpfe für Dinge zeigt, die
der Server nicht kann, ist schlimmer als eine, die sie weglässt. Plex
meldet hier ausdrücklich keine Favoriten und wirft, wenn jemand es
trotzdem versucht; stillschweigend nichts zu tun wäre die schlechtere
Antwort.

`MediaQuery` ersetzt `LibraryQuery` nach oben hin. Deren Sortierwerte
waren Plex' Drahtformat („titleSort:asc"), was hieß: entweder lernt
Jellyfin Plex' Schreibweise, oder jede Ansicht muss wissen, mit wem sie
spricht.

`PlexBackend` ist eine Hülle, keine Umschreibung — die Endpunkte bleiben,
wo sie sind. Der Fehlerübersetzer nimmt den ungetypten Wurf, weil Swift
getippte Fehler durch eine Closure hindurch nicht ableiten kann; der
zweite `catch` ist dabei keine Zierde, sondern verhindert, dass ein
unerwarteter Fehler durchfällt und die Signatur zur Lüge macht.

Die App benutzt noch nichts davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 12:37:37 +02:00
co-authored by Claude Opus 5
parent ba6be4ac79
commit effdcd4532
3 changed files with 346 additions and 0 deletions
@@ -0,0 +1,194 @@
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
public init(server: PlexServer) {
self.server = server
}
public nonisolated var serverName: String { server.name }
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), 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: - 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 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
)
}
}