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:
co-authored by
Claude Opus 5
parent
5b2b86b66b
commit
ba6be4ac79
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import PlexKit
|
||||
|
||||
/// Die Übersetzung der Plex-Modelle in die neutrale Form.
|
||||
///
|
||||
/// Gegen eine echte Server-Antwort, nicht gegen erfundene Werte: die
|
||||
/// Eigenheiten, an denen eine Übersetzung scheitert, stehen genau dort —
|
||||
/// „grandparent" für die Serie, Sekunden seit 1970 als Zahl, Fassungen und
|
||||
/// Dateien ineinander verschachtelt.
|
||||
@Suite("Übersetzung nach MediaKit")
|
||||
struct MediaTranslationTests {
|
||||
|
||||
private func item(_ fixture: String) throws -> PlexItem {
|
||||
let url = try #require(Bundle.module.url(forResource: "Fixtures/\(fixture)", withExtension: "json"))
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try Data(contentsOf: url)
|
||||
)
|
||||
return try #require(container.content.first)
|
||||
}
|
||||
|
||||
@Test("Aus dem Großelternteil wird die Serie")
|
||||
func translatesTheGrandparent() throws {
|
||||
let episode = try item("episodes-unanalyzed").asMediaItem
|
||||
|
||||
// Der Name war von Anfang an irreführend: gemeint ist die Serie, und
|
||||
// in jeder Ansicht musste man ihn im Kopf übersetzen.
|
||||
#expect(episode.kind == .episode)
|
||||
#expect(episode.title == "Die erste Folge")
|
||||
#expect(episode.seriesTitle == "Eine Serie")
|
||||
#expect(episode.index == 1)
|
||||
#expect(episode.parentIndex == 1)
|
||||
}
|
||||
|
||||
@Test("Fassungen und Dateien kommen vollständig mit")
|
||||
func translatesVersions() throws {
|
||||
let movie = try item("movie-with-two-versions").asMediaItem
|
||||
|
||||
#expect(movie.versions.count == 2)
|
||||
#expect(movie.hasMultipleVersions)
|
||||
|
||||
let first = try #require(movie.versions.first)
|
||||
#expect(first.files.isEmpty == false)
|
||||
#expect(first.container != nil)
|
||||
}
|
||||
|
||||
@Test("Die Wiedergabe-Entscheidung bekommt dasselbe Profil wie vorher")
|
||||
func buildsTheSameProfile() throws {
|
||||
// Beide Wege müssen dasselbe sagen — sonst spielte ein Titel nach dem
|
||||
// Umbau plötzlich über einen anderen Player.
|
||||
let plex = try item("movie-with-two-versions")
|
||||
let media = try #require(plex.media.first)
|
||||
let part = try #require(media.parts.first)
|
||||
|
||||
let viaPlex = media.profile(part: part)
|
||||
let viaMedia = try #require(plex.asMediaItem.versions.first)
|
||||
.profile(file: plex.asMediaItem.versions.first?.files.first)
|
||||
|
||||
#expect(viaPlex == viaMedia)
|
||||
}
|
||||
|
||||
@Test("Ohne Fortschritt gibt es keinen Balken")
|
||||
func leavesUnstartedItemsAlone() throws {
|
||||
let movie = try item("movie-with-two-versions").asMediaItem
|
||||
|
||||
// `watchProgress` ist bewusst `nil` statt 0: ein Balken bei null
|
||||
// Prozent sieht aus wie ein Fehler, nicht wie „noch nicht begonnen".
|
||||
if movie.resumeMilliseconds == nil || movie.resumeMilliseconds == 0 {
|
||||
#expect(movie.watchProgress == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Plex kennt keine Favoriten und behauptet auch keine")
|
||||
func doesNotInventFavourites() throws {
|
||||
#expect(try item("movie-with-two-versions").asMediaItem.isFavourite == false)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user