MediaKit: die neutralen Datensätze und die Übersetzung aus Plex

`MediaItem` und die Typen darunter — die Form, in der die Oberfläche
Titel sieht, unabhängig davon, welcher Server sie geliefert hat.

Die Namen sind dort geblieben, wo sie ohnehin neutral waren: `title`,
`summary`, `year`, `index`. Umbenannt wurde, was Plex-Jargon ist.
`ratingKey` heißt `id`, `viewOffset` heißt `resumeMilliseconds`, und aus
dem „Großelternteil" ist die Serie geworden, die von Anfang an gemeint
war — ein Name, den man in jeder Ansicht im Kopf übersetzen musste.

Zwei Dinge, die dabei ehrlicher werden:

`isFavourite` gibt es, aber Plex meldet immer `false`. Ein Feld, das ein
Server nicht füllt, ist besser als eine Fähigkeit, die es nur auf einem
gibt und die sich nirgends setzen ließe.

Dateigrößen sind jetzt `Int64`. Ein Remux überschreitet vier Gigabyte
mühelos.

Die Übersetzung steht in **einer** Datei und geht in **eine** Richtung.
Die Drahtmodelle bleiben, wo sie sind — sie tragen Eigenheiten, die nur
Plex hat, und die gehören nicht nach oben.

Geprüft wird gegen echte Server-Antworten, nicht gegen erfundene Werte:
genau dort stehen die Eigenheiten, an denen eine Übersetzung scheitert.
Ein Test vergleicht außerdem beide Wege zum Wiedergabe-Profil — sagten
sie Unterschiedliches, spielte ein Titel nach dem Umbau plötzlich über
einen anderen Player.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 12:33:55 +02:00
co-authored by Claude Opus 5
parent 5b2b86b66b
commit ba6be4ac79
6 changed files with 779 additions and 0 deletions
@@ -0,0 +1,22 @@
import Foundation
/// Was beim Sprechen mit einem Server schiefgehen kann.
///
/// Dieselben sechs Fälle galten schon für Plex — sie sind nicht serverbezogen,
/// sondern beschreiben die Lage, in der man steckt. Dass es sie jetzt nur
/// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in
/// lesbaren Text stand an zwei Stellen, und die beiden waren bereits
/// auseinandergelaufen.
public enum MediaBackendError: Error, Equatable, Sendable {
/// Der Token gilt nicht mehr. Führt zur Abmeldung, nicht zu einer Meldung.
case unauthorized
case notFound
/// Keine der bekannten Adressen antwortet.
case noReachableConnection
/// Der Server hat geantwortet, aber mit einem Fehler.
case server(status: Int)
/// Die Anfrage kam gar nicht an.
case transport(description: String)
/// Der Server hat geantwortet, aber anders als erwartet.
case decoding(description: String)
}
@@ -0,0 +1,254 @@
import Foundation
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
///
/// Die Form, in der die Oberfläche Titel sieht, egal von welchem Server sie
/// stammen. Plex und Jellyfin liefern dieselben Dinge unter anderen Namen;
/// jeder Server-Adapter übersetzt einmal hierher, und danach ist die Herkunft
/// ohne Belang.
///
/// Die Namen sind dort geblieben, wo sie ohnehin neutral waren — `title`,
/// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist:
/// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und
/// aus dem „Großelternteil" ist die Serie geworden, die damit gemeint war.
public struct MediaItem: Sendable, Equatable, Identifiable {
public enum Kind: String, Sendable, Codable, CaseIterable {
case movie, show, season, episode, clip, collection
}
/// Die Kennung, unter der der Server den Eintrag führt.
///
/// Bei Plex eine Zahl als Zeichenkette, bei Jellyfin eine GUID. Für die
/// App ist beides nur ein undurchsichtiger Schlüssel — **außer** beim
/// Herunterladen, wo er mit einem Vorsatz versehen wird, damit sich zwei
/// Server nicht ins Gehege kommen.
public let id: String
/// Der serverinterne Pfad, falls einer gebraucht wird.
public let path: String?
public let kind: Kind?
public let title: String
public let sortTitle: String?
public let summary: String?
public let year: Int?
/// Laufzeit in Millisekunden.
public let duration: Int?
public let contentRating: String?
/// Bewertung der Kritik, 0–10.
public let rating: Double?
/// Bewertung des Publikums, 0–10.
public let audienceRating: Double?
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
// beim Abruf, weil sie Token und Größenangaben braucht.
public let thumb: String?
public let art: String?
/// Das Bild der Staffel.
public let parentThumb: String?
/// Das Bild der Serie. Bei Plex hieß das „grandparent" — gemeint war immer
/// die Serie, und so heißt es jetzt auch.
public let seriesThumb: String?
public let seriesArt: String?
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
// Staffel.
public let index: Int?
public let parentIndex: Int?
public let parentTitle: String?
public let parentID: String?
public let seriesTitle: String?
public let seriesID: String?
// Fortschritt.
public let playCount: Int?
/// Wie weit gesehen wurde, in Millisekunden.
public let resumeMilliseconds: Int?
public let lastPlayedAt: Date?
public let addedAt: Date?
/// Ob der Titel als Favorit markiert ist.
///
/// Plex kennt das nicht und meldet immer `false`; Jellyfin führt eine
/// eigene Liste. Ein Feld, das ein Server nicht füllt, ist ehrlicher als
/// eine Fähigkeit, die es nur auf einem gibt.
public let isFavourite: Bool
// Zählwerte bei Serien und Staffeln.
public let episodeCount: Int?
public let playedEpisodeCount: Int?
public let childCount: Int?
/// Die Fassungen, in denen der Titel vorliegt.
public let versions: [MediaVersion]
// Schlagwörter. Beide Server liefern sie nur bei der Einzelabfrage mit,
// nicht in Listen — dort sind sie deshalb leer.
public let genres: [String]
public let directors: [String]
public let writers: [String]
public let cast: [MediaPerson]
public let markers: [MediaMarker]
/// Zusätzliche Bilder, etwa der Schriftzug eines Films.
public let artwork: [MediaArtwork]
public init(
id: String,
path: String? = nil,
kind: Kind? = nil,
title: String,
sortTitle: String? = nil,
summary: String? = nil,
year: Int? = nil,
duration: Int? = nil,
contentRating: String? = nil,
rating: Double? = nil,
audienceRating: Double? = nil,
thumb: String? = nil,
art: String? = nil,
parentThumb: String? = nil,
seriesThumb: String? = nil,
seriesArt: String? = nil,
index: Int? = nil,
parentIndex: Int? = nil,
parentTitle: String? = nil,
parentID: String? = nil,
seriesTitle: String? = nil,
seriesID: String? = nil,
playCount: Int? = nil,
resumeMilliseconds: Int? = nil,
lastPlayedAt: Date? = nil,
addedAt: Date? = nil,
isFavourite: Bool = false,
episodeCount: Int? = nil,
playedEpisodeCount: Int? = nil,
childCount: Int? = nil,
versions: [MediaVersion] = [],
genres: [String] = [],
directors: [String] = [],
writers: [String] = [],
cast: [MediaPerson] = [],
markers: [MediaMarker] = [],
artwork: [MediaArtwork] = []
) {
self.id = id
self.path = path
self.kind = kind
self.title = title
self.sortTitle = sortTitle
self.summary = summary
self.year = year
self.duration = duration
self.contentRating = contentRating
self.rating = rating
self.audienceRating = audienceRating
self.thumb = thumb
self.art = art
self.parentThumb = parentThumb
self.seriesThumb = seriesThumb
self.seriesArt = seriesArt
self.index = index
self.parentIndex = parentIndex
self.parentTitle = parentTitle
self.parentID = parentID
self.seriesTitle = seriesTitle
self.seriesID = seriesID
self.playCount = playCount
self.resumeMilliseconds = resumeMilliseconds
self.lastPlayedAt = lastPlayedAt
self.addedAt = addedAt
self.isFavourite = isFavourite
self.episodeCount = episodeCount
self.playedEpisodeCount = playedEpisodeCount
self.childCount = childCount
self.versions = versions
self.genres = genres
self.directors = directors
self.writers = writers
self.cast = cast
self.markers = markers
self.artwork = artwork
}
}
extension MediaItem {
/// Wie weit gesehen — als Anteil zwischen 0 und 1.
///
/// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent
/// sieht aus wie ein Fehler, nicht wie „noch nicht angefangen".
public var watchProgress: Double? {
guard let resumeMilliseconds, let duration, duration > 0, resumeMilliseconds > 0 else {
return nil
}
return min(Double(resumeMilliseconds) / Double(duration), 1)
}
public var isWatched: Bool { (playCount ?? 0) > 0 }
/// Ab wo fortgesetzt wird.
public var resumeTime: Duration {
.milliseconds(resumeMilliseconds ?? 0)
}
/// Die Fassung, die gespielt werden soll, wenn niemand wählt.
public var preferredVersion: MediaVersion? { versions.first }
public var hasMultipleVersions: Bool { versions.count > 1 }
/// Der Schriftzug des Films, falls der Server einen hat.
///
/// Viele Filme bringen ihr eigenes Logo mit — Harry Potter in seiner
/// Schrift sagt mehr als derselbe Titel in der Systemschrift.
public var clearLogoPath: String? {
artwork.first { $0.kind == .clearLogo }?.path
}
}
/// Jemand, der mitgewirkt hat.
public struct MediaPerson: Sendable, Equatable, Identifiable {
public let id: String
public let name: String
/// Die Rolle, sofern es eine gibt.
public let role: String?
public let thumb: String?
public init(id: String, name: String, role: String? = nil, thumb: String? = nil) {
self.id = id
self.name = name
self.role = role
self.thumb = thumb
}
}
/// Ein Abschnitt, den man überspringen möchte.
public struct MediaMarker: Sendable, Equatable {
public enum Kind: String, Sendable {
case intro, credits, commercial, unknown
}
public let kind: Kind
public let startMilliseconds: Int
public let endMilliseconds: Int
public init(kind: Kind, startMilliseconds: Int, endMilliseconds: Int) {
self.kind = kind
self.startMilliseconds = startMilliseconds
self.endMilliseconds = endMilliseconds
}
public func contains(_ milliseconds: Int) -> Bool {
milliseconds >= startMilliseconds && milliseconds < endMilliseconds
}
}
/// Ein zusätzliches Bild zu einem Titel.
public struct MediaArtwork: Sendable, Equatable {
public enum Kind: String, Sendable {
case poster, background, banner, clearLogo, unknown
}
public let kind: Kind
public let path: String
public init(kind: Kind, path: String) {
self.kind = kind
self.path = path
}
}
@@ -0,0 +1,65 @@
import Foundation
/// Eine Mediathek auf dem Server.
public struct MediaLibrary: Sendable, Equatable, Identifiable {
public enum Kind: String, Sendable, Equatable {
case movie, show, music, photo, liveTV, other
}
public let id: String
public let title: String
public let kind: Kind
public let thumb: String?
public let art: String?
public init(
id: String,
title: String,
kind: Kind,
thumb: String? = nil,
art: String? = nil
) {
self.id = id
self.title = title
self.kind = kind
self.thumb = thumb
self.art = art
}
/// Ob die App mit dieser Mediathek etwas anfangen kann.
///
/// Musik und Fotos sind bewusst außen vor: sie bräuchten eigene Ansichten
/// und eigene Wiedergabe, und eine halbherzige Musikansicht ist schlechter
/// als gar keine.
public var isSupported: Bool { kind == .movie || kind == .show }
}
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
public struct MediaRowGroup: Sendable, Equatable, Identifiable {
/// Eine stabile Kennung für diese Reihe.
///
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
/// hat — und muss deshalb über Abrufe hinweg gleich bleiben, auch wenn
/// sich der angezeigte Titel ändert.
public let id: String
public let title: String
/// Der Pfad, unter dem es „alles davon" gäbe.
public let moreKey: String?
public let items: [MediaItem]
public init(id: String, title: String, moreKey: String? = nil, items: [MediaItem] = []) {
self.id = id
self.title = title
self.moreKey = moreKey
self.items = items
}
/// Ob diese Reihe das Weiterschauen ist.
///
/// Sie wird anders dargestellt: dort zeigt das Szenenbild, wo man
/// aufgehört hat, während man sonst nach Plakaten sucht.
public var isContinueWatching: Bool {
let lowered = id.lowercased()
return lowered.contains("continue") || lowered.contains("resume") || lowered.contains("nextup")
}
}
@@ -0,0 +1,173 @@
import Foundation
/// Eine Fassung eines Titels — eine bestimmte Datei in einer bestimmten Güte.
///
/// Ein Film kann mehrfach vorliegen: als 4K-Remux und als kleine Fassung fürs
/// Handy. Welche gespielt wird, entscheidet der Nutzer oder die Voreinstellung.
public struct MediaVersion: Sendable, Equatable, Identifiable {
public let id: String
public let container: String?
public let videoCodec: String?
public let audioCodec: String?
/// Die Auflösung, wie der Server sie nennt — „1080", „4k".
public let videoResolution: String?
/// Gesamtbitrate in Kilobit je Sekunde.
public let bitrate: Int?
public let width: Int?
public let height: Int?
public let audioChannels: Int?
public let duration: Int?
public let files: [MediaFile]
public init(
id: String,
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoResolution: String? = nil,
bitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
audioChannels: Int? = nil,
duration: Int? = nil,
files: [MediaFile] = []
) {
self.id = id
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoResolution = videoResolution
self.bitrate = bitrate
self.width = width
self.height = height
self.audioChannels = audioChannels
self.duration = duration
self.files = files
}
/// Die Höhe in Bildzeilen.
///
/// Nicht immer gemeldet: manche Server nennen nur „4k" und keine Zahl.
/// Danach richtet sich, welche Qualitätsstufen überhaupt Sinn ergeben —
/// eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu.
public var pixelHeight: Int? {
if let height { return height }
switch videoResolution?.lowercased() {
case "4k": return 2_160
case "1080": return 1_080
case "720": return 720
case "480": return 480
case "sd": return 480
default: return nil
}
}
/// Was die Wiedergabe-Entscheidung über diese Fassung wissen muss.
public func profile(file: MediaFile?) -> MediaProfile {
MediaProfile(
container: file?.container ?? container,
videoCodec: videoCodec ?? file?.videoStream?.codec,
// Die **gewählte** Tonspur, nicht irgendeine: eine Datei mit
// deutschem AAC und englischem DTS ist für AVPlayer in Ordnung,
// solange Deutsch klingt.
audioCodec: file?.selectedAudioStream?.codec ?? audioCodec,
bitrate: bitrate,
hasKnownStreams: !(file?.streams.isEmpty ?? true)
)
}
}
/// Eine Datei auf dem Server.
public struct MediaFile: Sendable, Equatable, Identifiable {
public let id: String
/// Der Pfad, über den die Datei abgerufen wird.
public let key: String?
public let container: String?
/// Größe in Bytes.
///
/// `Int64`, nicht `Int`: ein Remux überschreitet vier Gigabyte mühelos,
/// und auf einer 32-Bit-Plattform wäre die Zahl dann falsch.
public let size: Int64?
public let duration: Int?
/// Der Pfad im Dateisystem des Servers — nur zur Anzeige.
public let file: String?
public let streams: [MediaStream]
public init(
id: String,
key: String? = nil,
container: String? = nil,
size: Int64? = nil,
duration: Int? = nil,
file: String? = nil,
streams: [MediaStream] = []
) {
self.id = id
self.key = key
self.container = container
self.size = size
self.duration = duration
self.file = file
self.streams = streams
}
public var videoStream: MediaStream? { streams.first { $0.kind == .video } }
public var audioStreams: [MediaStream] { streams.filter { $0.kind == .audio } }
public var subtitleStreams: [MediaStream] { streams.filter { $0.kind == .subtitle } }
/// Die Tonspur, die klingen soll.
///
/// Die ausdrücklich gewählte, sonst die erste. Daran hängt die
/// Entscheidung, ob AVPlayer die Datei öffnen kann.
public var selectedAudioStream: MediaStream? {
audioStreams.first { $0.isSelected } ?? audioStreams.first
}
}
/// Eine Spur in einer Datei.
public struct MediaStream: Sendable, Equatable, Identifiable {
public enum Kind: Sendable, Equatable { case video, audio, subtitle }
public let id: String
public let kind: Kind
public let codec: String?
public let language: String?
public let languageCode: String?
/// Wie der Server die Spur nennt — etwa „Deutsch (AC3 5.1)".
public let displayTitle: String?
public let channels: Int?
public let isSelected: Bool
/// Untertitel, die auch bei abgeschalteten Untertiteln erscheinen sollen.
public let isForced: Bool
public init(
id: String,
kind: Kind,
codec: String? = nil,
language: String? = nil,
languageCode: String? = nil,
displayTitle: String? = nil,
channels: Int? = nil,
isSelected: Bool = false,
isForced: Bool = false
) {
self.id = id
self.kind = kind
self.codec = codec
self.language = language
self.languageCode = languageCode
self.displayTitle = displayTitle
self.channels = channels
self.isSelected = isSelected
self.isForced = isForced
}
/// Ein brauchbarer Name, auch wenn der Server keinen liefert.
///
/// „Spur 3" hilft niemandem bei der Wahl; Sprache und Codec schon.
public var name: String {
if let displayTitle, !displayTitle.isEmpty { return displayTitle }
let parts = [language, codec?.uppercased()].compactMap(\.self)
return parts.isEmpty ? "—" : parts.joined(separator: " · ")
}
}
@@ -0,0 +1,186 @@
import Foundation
import MediaKit
/// Die Übersetzung der Plex-Drahtmodelle in die neutrale Form.
///
/// Eine Datei, eine Richtung. Danach ist unerheblich, von welchem Server ein
/// Titel stammt — und genau das war das Ziel: die Oberfläche soll nicht an
/// dreißig Stellen wissen müssen, wie Plex seine Felder nennt.
///
/// Die Drahtmodelle bleiben, wo sie sind. Sie tragen Eigenheiten, die nur
/// Plex hat (dynamische Schlüssel in der Hülle, `grandparent` für die Serie,
/// Sekunden seit 1970 als Zahl), und die gehören nicht nach oben.
extension PlexItem {
public var asMediaItem: MediaItem {
MediaItem(
id: ratingKey,
path: key,
kind: kind.map(Self.kind) ?? nil,
title: title,
sortTitle: titleSort,
summary: summary,
year: year,
duration: duration,
contentRating: contentRating,
rating: rating,
audienceRating: audienceRating,
thumb: thumb,
art: art,
parentThumb: parentThumb,
seriesThumb: grandparentThumb,
seriesArt: grandparentArt,
index: index,
parentIndex: parentIndex,
parentTitle: parentTitle,
parentID: parentRatingKey,
seriesTitle: grandparentTitle,
seriesID: grandparentRatingKey,
playCount: viewCount,
resumeMilliseconds: viewOffset,
lastPlayedAt: lastViewedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) },
addedAt: addedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) },
// Plex kennt keine Favoriten. Ehrlicher als eine erfundene
// Markierung, die sich nirgends setzen ließe.
isFavourite: false,
episodeCount: leafCount,
playedEpisodeCount: viewedLeafCount,
childCount: childCount,
versions: media.map(\.asMediaVersion),
genres: genres.map(\.tag),
directors: directors.map(\.tag),
writers: writers.map(\.tag),
cast: roles.map(\.asMediaPerson),
markers: markers.map(\.asMediaMarker),
artwork: images.map(\.asMediaArtwork)
)
}
private static func kind(_ plex: PlexItem.Kind) -> MediaItem.Kind {
switch plex {
case .movie: .movie
case .show: .show
case .season: .season
case .episode: .episode
case .clip: .clip
case .collection: .collection
}
}
}
extension PlexMedia {
public var asMediaVersion: MediaVersion {
MediaVersion(
id: String(id),
container: container,
videoCodec: videoCodec,
audioCodec: audioCodec,
videoResolution: videoResolution,
bitrate: bitrate,
width: width,
height: height,
audioChannels: audioChannels,
duration: duration,
files: parts.map(\.asMediaFile)
)
}
}
extension PlexPart {
public var asMediaFile: MediaFile {
MediaFile(
id: String(id),
key: key,
container: container,
size: size,
duration: duration,
file: file,
streams: streams.compactMap(\.asMediaStream)
)
}
}
extension PlexStream {
/// `nil`, wenn Plex eine Spurart meldet, mit der die App nichts anfängt.
public var asMediaStream: MediaStream? {
let mapped: MediaStream.Kind
switch kind {
case .video: mapped = .video
case .audio: mapped = .audio
case .subtitle: mapped = .subtitle
case nil: return nil
}
return MediaStream(
id: String(id),
kind: mapped,
codec: codec,
language: language,
languageCode: languageCode,
displayTitle: displayTitle ?? title,
channels: channels,
isSelected: isSelected,
isForced: isForced
)
}
}
extension PlexTag {
public var asMediaPerson: MediaPerson {
MediaPerson(id: id, name: tag, role: role, thumb: thumb)
}
}
extension PlexMarker {
public var asMediaMarker: MediaMarker {
MediaMarker(
kind: MediaMarker.Kind(rawValue: type) ?? .unknown,
startMilliseconds: startTimeOffset,
endMilliseconds: endTimeOffset
)
}
}
extension PlexImage {
public var asMediaArtwork: MediaArtwork {
MediaArtwork(
kind: MediaArtwork.Kind(rawValue: type) ?? .unknown,
path: url
)
}
}
extension PlexLibrary {
public var asMediaLibrary: MediaLibrary {
MediaLibrary(
id: key,
title: title,
kind: mediaKind,
thumb: thumb,
art: art
)
}
}
extension PlexLibrary {
private var mediaKind: MediaLibrary.Kind {
switch kind {
case .movie: .movie
case .show: .show
case .artist: .music
case .photo: .photo
case .livetv: .liveTV
case nil: .other
}
}
}
extension PlexHub {
public var asMediaRowGroup: MediaRowGroup {
MediaRowGroup(
id: hubIdentifier,
title: title,
moreKey: more == true ? key : nil,
items: items.map(\.asMediaItem)
)
}
}