Auf der Detailseite stand „1080" — die Zeichenkette des Servers in Grossbuchstaben. Weder eine Zahl mit Bedeutung noch ein Name, den jemand benutzt. Jetzt steht dort „Full HD", „4K", „HD", „SD". Die Einstufung geht neu über die **Breite**, nicht über die Höhe. Ein Kinofilm in 2,39:1 misst 1920 × 804; nach der Höhe war das bisher „720p", und zwar ausgerechnet bei den grossen Filmen. Die Höhe bleibt als Notnagel für Server, die keine Breite melden, und der Name des Servers als letzter. Nebenbei fällt damit der 4K-Filter bei Jellyfin nicht mehr durch, wenn ein breitwandiges 4K nur 1608 Zeilen hat, und die doppelte Einstufung in PlexMedia verschwindet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223 lines
8.1 KiB
Swift
223 lines
8.1 KiB
Swift
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, height > 0 { return height }
|
|
return resolution?.nominalHeight
|
|
}
|
|
|
|
/// Die Stufe, in der diese Fassung vorliegt.
|
|
public var resolution: VideoResolution? {
|
|
VideoResolution(width: width, height: height, serverLabel: videoResolution)
|
|
}
|
|
|
|
/// Die Auflösung als Wort — „4K", „Full HD".
|
|
///
|
|
/// Nicht die Zeichenkette des Servers: Plex sagt „4k" und „1080",
|
|
/// Jellyfin gibt Zahlen. Gemeint ist dasselbe, und keines davon ist ein
|
|
/// Name, den jemand benutzt.
|
|
public var resolutionLabel: String? { resolution?.name }
|
|
|
|
/// 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
|
|
/// Wie die Spur **in der Datei** heißt.
|
|
///
|
|
/// Das zweite Signal neben dem Sprachkürzel — und manchmal das einzige
|
|
/// richtige. Wo im Kürzel „eng" steht, kann hier „Deutsch" stehen; genau
|
|
/// so sehen die Dateien aus, bei denen die Sprachwahl bisher danebengriff.
|
|
public let title: String?
|
|
/// Die Spur, die der Server als Standard markiert.
|
|
public let isDefault: Bool
|
|
/// Für Hörgeschädigte. **Nicht** Audiodeskription — dafür gibt es bei
|
|
/// Jellyfin kein Kennzeichen, die lässt sich nur am Namen erkennen.
|
|
public let isHearingImpaired: Bool
|
|
/// Die Originaltonspur, sofern die Datei das vermerkt.
|
|
public let isOriginal: 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,
|
|
title: String? = nil,
|
|
isDefault: Bool = false,
|
|
isHearingImpaired: Bool = false,
|
|
isOriginal: 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
|
|
self.title = title
|
|
self.isDefault = isDefault
|
|
self.isHearingImpaired = isHearingImpaired
|
|
self.isOriginal = isOriginal
|
|
}
|
|
|
|
/// Die Nummer, unter der der Server diese Spur führt.
|
|
public var index: Int? { Int(id) }
|
|
|
|
/// 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: " · ")
|
|
}
|
|
}
|