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,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: " · ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user