Jeder Aufruf einer Mediathek war ein Serverabruf mit Ladekreis davor. Die Plakate lagen längst auf der Platte; was fehlte, war die Liste, die sagt, welche davon hierhergehören. Jetzt liegt die erste Seite als Abzug daneben: beim Aufrufen steht sie sofort da, der Server wird still nachgefragt, und man sieht in aller Regel nichts davon. Antwortet er nicht, bleibt der Abzug stehen — eine Liste von gestern ist im Zug brauchbarer als ein Fehlerbildschirm. Bewusst nur die erste Seite. Stünden 240 gespeicherte Titel da und der Server antwortet mit 60, müsste entweder die Liste vor den Augen schrumpfen oder der Versatz fürs Nachladen wäre falsch. Gespeichert wird ausserdem nur, was eine Kachel zeigt. Ein Titel aus einer Listenabfrage bringt jede Spur jeder Datei mit — im Raster steht davon nichts, und ausgepackt werden müsste es bei jedem Aufrufen, also genau da, wo der Abzug Zeit sparen soll. Die Mediathekenliste selbst wird nur als Rettungsanker gemerkt, nicht als Beschleuniger: sie sofort zu zeigen hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der Token noch gilt. Abmelden räumt alles weg. Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223 lines
8.2 KiB
Swift
223 lines
8.2 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, Codable {
|
|
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, Codable {
|
|
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, Codable {
|
|
public enum Kind: String, Sendable, Equatable, Codable { 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: " · ")
|
|
}
|
|
}
|