Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. 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: " · ")
|
|
}
|
|
}
|