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>
166 lines
6.6 KiB
Swift
166 lines
6.6 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Eine Datei auf der Platte des Servers.
|
|
///
|
|
/// Plex schachtelt hier zweifach: ein Titel hat mehrere `Media` (dieselbe
|
|
/// Fassung in 4K und 1080p), und jedes `Media` hat `Part` (ein Film, der auf
|
|
/// zwei Dateien verteilt ist). Für die Wiedergabe zählt der `Part` — er trägt
|
|
/// den Pfad.
|
|
public struct PlexPart: Sendable, Equatable, Codable, Identifiable {
|
|
public let id: Int
|
|
/// Der Pfad, unter dem die Datei abrufbar ist.
|
|
public let key: String
|
|
public let container: String?
|
|
public let size: Int64?
|
|
public let duration: Int?
|
|
public let file: String?
|
|
public let streams: [PlexStream]
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, key, container, size, duration, file
|
|
// Plex schreibt verschachtelte Sammlungen groß.
|
|
case streams = "Stream"
|
|
}
|
|
|
|
public init(
|
|
id: Int,
|
|
key: String,
|
|
container: String? = nil,
|
|
size: Int64? = nil,
|
|
duration: Int? = nil,
|
|
file: String? = nil,
|
|
streams: [PlexStream] = []
|
|
) {
|
|
self.id = id
|
|
self.key = key
|
|
self.container = container
|
|
self.size = size
|
|
self.duration = duration
|
|
self.file = file
|
|
self.streams = streams
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.id = try container.decode(Int.self, forKey: .id)
|
|
self.key = try container.decode(String.self, forKey: .key)
|
|
self.container = try container.decodeIfPresent(String.self, forKey: .container)
|
|
self.size = try container.decodeIfPresent(Int64.self, forKey: .size)
|
|
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
|
|
self.file = try container.decodeIfPresent(String.self, forKey: .file)
|
|
// Fehlt die Spurenliste, ist die Datei noch nicht analysiert. Das ist
|
|
// kein Fehler — es heißt nur, dass wir nicht über DirectPlay
|
|
// entscheiden können und im Zweifel transcodieren lassen.
|
|
self.streams = try container.decodeIfPresent([PlexStream].self, forKey: .streams) ?? []
|
|
}
|
|
|
|
public var videoStream: PlexStream? { streams.first { $0.kind == .video } }
|
|
public var audioStreams: [PlexStream] { streams.filter { $0.kind == .audio } }
|
|
public var subtitleStreams: [PlexStream] { streams.filter { $0.kind == .subtitle } }
|
|
/// Die Tonspur, die der Server als aktiv meldet — sonst die erste.
|
|
public var selectedAudioStream: PlexStream? {
|
|
audioStreams.first { $0.isSelected } ?? audioStreams.first
|
|
}
|
|
}
|
|
|
|
/// Eine Fassung eines Titels.
|
|
public struct PlexMedia: Sendable, Equatable, Codable, Identifiable {
|
|
public let id: Int
|
|
public let container: String?
|
|
public let videoCodec: String?
|
|
public let audioCodec: String?
|
|
/// Plex meldet hier Werte wie `"1080"`, `"4k"` oder `"sd"` — keine Zahl.
|
|
public let videoResolution: String?
|
|
public let videoProfile: String?
|
|
/// In Kilobit pro Sekunde.
|
|
public let bitrate: Int?
|
|
public let width: Int?
|
|
public let height: Int?
|
|
public let audioChannels: Int?
|
|
public let duration: Int?
|
|
public let parts: [PlexPart]
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, container, videoCodec, audioCodec, videoResolution, videoProfile
|
|
case bitrate, width, height, audioChannels, duration
|
|
case parts = "Part"
|
|
}
|
|
|
|
public init(
|
|
id: Int,
|
|
container: String? = nil,
|
|
videoCodec: String? = nil,
|
|
audioCodec: String? = nil,
|
|
videoResolution: String? = nil,
|
|
videoProfile: String? = nil,
|
|
bitrate: Int? = nil,
|
|
width: Int? = nil,
|
|
height: Int? = nil,
|
|
audioChannels: Int? = nil,
|
|
duration: Int? = nil,
|
|
parts: [PlexPart] = []
|
|
) {
|
|
self.id = id
|
|
self.container = container
|
|
self.videoCodec = videoCodec
|
|
self.audioCodec = audioCodec
|
|
self.videoResolution = videoResolution
|
|
self.videoProfile = videoProfile
|
|
self.bitrate = bitrate
|
|
self.width = width
|
|
self.height = height
|
|
self.audioChannels = audioChannels
|
|
self.duration = duration
|
|
self.parts = parts
|
|
}
|
|
|
|
public init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.id = try container.decode(Int.self, forKey: .id)
|
|
self.container = try container.decodeIfPresent(String.self, forKey: .container)
|
|
self.videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec)
|
|
self.audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec)
|
|
self.videoResolution = try container.decodeIfPresent(String.self, forKey: .videoResolution)
|
|
self.videoProfile = try container.decodeIfPresent(String.self, forKey: .videoProfile)
|
|
self.bitrate = try container.decodeIfPresent(Int.self, forKey: .bitrate)
|
|
self.width = try container.decodeIfPresent(Int.self, forKey: .width)
|
|
self.height = try container.decodeIfPresent(Int.self, forKey: .height)
|
|
self.audioChannels = try container.decodeIfPresent(Int.self, forKey: .audioChannels)
|
|
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
|
|
self.parts = try container.decodeIfPresent([PlexPart].self, forKey: .parts) ?? []
|
|
}
|
|
|
|
/// Die Bildhöhe in Bildpunkten.
|
|
///
|
|
/// Bevorzugt aus `height`. Fehlt die, wird `videoResolution` gedeutet —
|
|
/// dort steht bei Plex kein Zahlenwert, sondern „1080", „4k" oder „sd".
|
|
public var pixelHeight: Int? {
|
|
if let height, height > 0 { return height }
|
|
guard let videoResolution = videoResolution?.lowercased() else { return nil }
|
|
switch videoResolution {
|
|
case "4k": return 2160
|
|
case "sd": return 480
|
|
default: return Int(videoResolution)
|
|
}
|
|
}
|
|
|
|
/// Die Stufe, in der diese Fassung vorliegt.
|
|
public var resolution: VideoResolution? {
|
|
VideoResolution(width: width, height: height, serverLabel: videoResolution)
|
|
}
|
|
|
|
/// Die Auflösung in der Schreibweise, die Menschen erwarten.
|
|
public var resolutionLabel: String? { resolution?.name }
|
|
|
|
/// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite,
|
|
/// etwa „4K · HEVC · 38,2 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: " · ")
|
|
}
|
|
}
|