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