Player hing sich auf Die Qualitätsauswahl war ein Menu. Schloss man es durch einen Tipp ins Bild, blieb eine unsichtbare Ebene zurück, die alle weiteren Tipps schluckte — die Bedienelemente kamen nicht mehr, pausieren ging nicht mehr. Jetzt ein confirmationDialog, der sich selbst wegräumt. Nach dem Schließen werden die Bedienelemente eingeblendet und die Ausblendfrist neu gestartet. Downloads galten als fertig, waren aber leer Zwei Fehler übereinander. Erstens gilt ein URLSessionDownloadTask auch bei 404 oder 500 als erfolgreich und legt die Fehlerseite als Datei ab. Zweitens überschrieb ich danach die erwartete Größe mit der empfangenen — womit die Anzeige auf hundert Prozent stand und der Beweis weg war. 873 MB waren in einer Sekunde „fertig", und beim Abspielen kam nur „Wiedergabe wurde unterbrochen". Jetzt wird der Statuscode geprüft, die erwartete Größe bleibt stehen, und eine unplausible Datei wird verworfen statt verbucht: unter 100 KB ist keine Videodatei, unter der Hälfte der angekündigten Größe ist eine abgebrochene Übertragung. Sechs Tests dazu. Stufenliste ergab keinen Sinn Für einen 720p-Inhalt standen Original, Full HD und HD mit derselben geschätzten Größe untereinander — das sah aus, als wäre die Schätzung kaputt. Sie war es nicht, die Stufen waren es: hochrechnen bringt nur eine größere Datei. Angeboten wird jetzt nur noch, was unter der Auflösung der Datei liegt, und beim Original steht dabei, was es ist: „Original · 720p · 873 MB". Kopfleiste Kein Hintergrund mehr statt eines weichgezeichneten — die Knöpfe bringen ihre eigene Glasfläche mit. Der Titel ist weg: der Name der App gehört auf den Homescreen, nicht über jeden Bildschirm. 145 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
6.7 KiB
Swift
169 lines
6.7 KiB
Swift
import Foundation
|
|
|
|
/// 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 Auflösung in der Schreibweise, die Menschen erwarten.
|
|
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 auf der Detailseite,
|
|
/// etwa „4K · HEVC · 38,2 Mbit/s".
|
|
public var qualityLabel: String {
|
|
var pieces: [String] = []
|
|
if let videoResolution { pieces.append(videoResolution.uppercased()) }
|
|
if let videoCodec { pieces.append(videoCodec.uppercased()) }
|
|
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
|
|
return pieces.joined(separator: " · ")
|
|
}
|
|
}
|