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:
co-authored by
Claude Opus 5
parent
ba6be4ac79
commit
effdcd4532
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
/// Was ein Server können muss, damit die App mit ihm arbeiten kann.
|
||||
///
|
||||
/// Der Vertrag ist bewusst schmal. Alles, was nur ein Server kann — Plex' PIN-
|
||||
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
||||
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
||||
/// der alles umfasst, wäre kein Nenner mehr.
|
||||
public protocol MediaBackend: Sendable {
|
||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||
nonisolated var serverName: String { get }
|
||||
/// Was dieser Server darüber hinaus beherrscht.
|
||||
nonisolated var capabilities: Set<BackendCapability> { get }
|
||||
|
||||
// MARK: - Blättern
|
||||
|
||||
func libraries() async throws(MediaBackendError) -> [MediaLibrary]
|
||||
func homeRows() async throws(MediaBackendError) -> [MediaRowGroup]
|
||||
func items(
|
||||
inLibrary library: MediaLibrary,
|
||||
query: MediaQuery,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
) async throws(MediaBackendError) -> MediaPage
|
||||
func item(id: String) async throws(MediaBackendError) -> MediaItem
|
||||
func children(of id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||
func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup]
|
||||
/// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek.
|
||||
func filterOptions(
|
||||
inLibrary library: MediaLibrary,
|
||||
field: MediaQuery.Field
|
||||
) async throws(MediaBackendError) -> [MediaFilterOption]
|
||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||
|
||||
// MARK: - Bilder
|
||||
|
||||
/// Ein Bild in der gewünschten Größe.
|
||||
///
|
||||
/// `nonisolated`, weil die Adresse ohne Netzzugriff entsteht und der
|
||||
/// Systemplayer sie ohne Kopfzeilen abruft — der Token steckt deshalb in
|
||||
/// der Adresse selbst.
|
||||
nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL?
|
||||
nonisolated func rawImageURL(path: String?) -> URL?
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
func markWatched(id: String) async throws(MediaBackendError)
|
||||
func markUnwatched(id: String) 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)
|
||||
}
|
||||
|
||||
/// Was ein Server über das Nötigste hinaus kann.
|
||||
///
|
||||
/// Abgefragt statt vorausgesetzt: 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.
|
||||
public enum BackendCapability: String, Sendable, Hashable {
|
||||
case liveTV
|
||||
case favourites
|
||||
case ratings
|
||||
case libraryScan
|
||||
/// Die Verwaltung des Servers aus der App heraus.
|
||||
case administration
|
||||
case transcoding
|
||||
}
|
||||
|
||||
/// Eine Seite aus einer Mediathek.
|
||||
public struct MediaPage: Sendable, Equatable {
|
||||
public let items: [MediaItem]
|
||||
/// Wie viele es insgesamt gibt — Grundlage fürs Nachladen.
|
||||
public let totalCount: Int?
|
||||
|
||||
public init(items: [MediaItem], totalCount: Int? = nil) {
|
||||
self.items = items
|
||||
self.totalCount = totalCount
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein möglicher Wert für einen Filter.
|
||||
public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
public init(id: String, title: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Wonach eine Mediathek durchsucht wird.
|
||||
///
|
||||
/// Gefiltert und sortiert wird **auf dem Server**. Alles hierher zu holen und
|
||||
/// selbst zu sieben wäre bei einer Mediathek mit fünftausend Folgen
|
||||
/// aussichtslos — und die Server können es ohnehin besser.
|
||||
///
|
||||
/// Die Sortierarten sind neutral benannt. Vorher standen hier Plex'
|
||||
/// Drahtwerte (`"titleSort:asc"`), was bedeutete: entweder Jellyfin lernt
|
||||
/// Plex' Schreibweise, oder jede Ansicht muss wissen, mit wem sie spricht.
|
||||
public struct MediaQuery: Sendable, Equatable {
|
||||
public enum Sort: String, Sendable, Equatable, CaseIterable {
|
||||
case titleAscending, titleDescending
|
||||
case recentlyAdded
|
||||
case releaseDate
|
||||
case rating
|
||||
case lastPlayed
|
||||
}
|
||||
|
||||
/// Wonach gefiltert werden kann.
|
||||
public enum Field: String, Sendable, Equatable {
|
||||
case genre, year, contentRating, resolution
|
||||
}
|
||||
|
||||
public var sort: Sort
|
||||
public var unwatchedOnly: Bool
|
||||
public var favouritesOnly: Bool
|
||||
public var genre: String?
|
||||
public var year: Int?
|
||||
public var contentRating: String?
|
||||
public var resolution: String?
|
||||
/// Freitextsuche innerhalb der Mediathek.
|
||||
public var searchText: String?
|
||||
|
||||
public init(
|
||||
sort: Sort = .titleAscending,
|
||||
unwatchedOnly: Bool = false,
|
||||
favouritesOnly: Bool = false,
|
||||
genre: String? = nil,
|
||||
year: Int? = nil,
|
||||
contentRating: String? = nil,
|
||||
resolution: String? = nil,
|
||||
searchText: String? = nil
|
||||
) {
|
||||
self.sort = sort
|
||||
self.unwatchedOnly = unwatchedOnly
|
||||
self.favouritesOnly = favouritesOnly
|
||||
self.genre = genre
|
||||
self.year = year
|
||||
self.contentRating = contentRating
|
||||
self.resolution = resolution
|
||||
self.searchText = searchText
|
||||
}
|
||||
|
||||
/// Ob überhaupt eingeschränkt wird — steuert die Anzeige des Filterknopfs.
|
||||
public var isFiltering: Bool {
|
||||
unwatchedOnly || favouritesOnly || genre != nil || year != nil
|
||||
|| contentRating != nil || resolution != nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user