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 } } /// Die Auflösung als Wort — „4K", „1080p". /// /// Aus der Bildhöhe, nicht aus dem Namen des Servers: Plex sagt „4k", /// Jellyfin gibt Zahlen. Gemeint ist dasselbe. public var resolutionLabel: String? { guard let pixelHeight else { return videoResolution?.uppercased() } switch pixelHeight { case 1_600...: return "4K" case 1_000..<1_600: return "1080p" case 700..<1_000: return "720p" case 1..<700: return "SD" default: return nil } } /// Eine kurze Beschreibung für die Versionsauswahl, etwa /// „4K · HEVC · 38 Mbit/s". public var qualityLabel: String { var pieces: [String] = [] if let label = resolutionLabel { pieces.append(label) } if let videoCodec { pieces.append(videoCodec.uppercased()) } if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") } return pieces.joined(separator: " · ") } /// 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? /// Zu welchem Titel diese Datei gehört. /// /// Plex adressiert Dateien unmittelbar und braucht das nicht; Jellyfin /// adressiert über den Titel und nennt die Datei nur als Beiwerk. Ohne /// diese Angabe traf die Adresse dort den falschen Weg, sobald ein Titel /// in mehreren Fassungen vorliegt — und nur dann, was den Fehler zu einem /// gemacht hätte, den man erst spät findet. public let itemID: 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, itemID: String? = nil, container: String? = nil, size: Int64? = nil, duration: Int? = nil, file: String? = nil, streams: [MediaStream] = [] ) { self.id = id self.key = key self.itemID = itemID 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: " · ") } }