Flixy heißt ab hier Halo

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>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,342 @@
import Foundation
import MediaKit
extension JellyfinBackend {
// MARK: - Abspielen
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
// Jellyfin trennt Fassung und Datei nicht: was die neutrale Form
// „Datei" nennt, ist dort die Quelle, und ihre Kennung wird bei jedem
// weiteren Schritt wieder verlangt.
let sourceID = intent.file.key ?? intent.itemID
guard let transcode = intent.transcode else {
// `static=true` schaltet jede Umrechnung ab: die Datei kommt so,
// wie sie auf der Platte liegt. Ob das Gerät sie überhaupt
// abspielen kann, ist vorher entschieden worden — und wenn nicht
// der Systemplayer.
guard let url = client.url(
path: "/Videos/\(intent.itemID)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: sourceID),
],
includingToken: account.token
) else { throw .notFound }
// Auch hier eine Sitzungskennung, und zwar eine selbst vergebene:
// ohne sie ordnet Jellyfin die Fortschrittsmeldungen keiner
// Wiedergabe zu, und der Titel bliebe für den Server ungesehen.
return PlaybackTarget(
url: url,
sessionID: UUID().uuidString,
versionID: sourceID
)
}
let info = try await playbackInfo(
itemID: intent.itemID,
sourceID: sourceID,
transcode: transcode,
audioStreamIndex: intent.audioStreamIndex,
subtitleStreamIndex: intent.subtitleStreamIndex,
remux: transcode.prefersRemux
)
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
// Die Adresse kommt vom Server, nicht von uns. Er weiß, welche Spuren
// er kopieren kann und welche er neu rechnen muss; eine selbst
// zusammengebaute Adresse wäre eine Vermutung darüber.
guard let path = source?.transcodingURL,
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL
else {
throw .refused(reason: String(
localized: "player.transcodeUnavailable",
bundle: .module
))
}
var adresse = Self.limiting(height: transcode.resolution, in: url)
// **Immer die Master-Liste, auch ohne Leiter.**
//
// Der Kommentar, der hier stand, war überholt und hat einen Fehler
// verdeckt: er stammte aus der Zeit, als Umpacken eine fortlaufende
// MP4 war — da wäre eine Abspielliste tatsächlich verkehrt gewesen.
// Seit das Umpacken über HLS läuft, ist sie genau richtig.
//
// Und sie ist nicht nur richtig, sie ist notwendig: eine
// Untertitelspur wird mit `#EXT-X-MEDIA` angekündigt, und das steht
// ausschliesslich in einer Master-Liste. Auf `main.m3u8` kann es
// strukturell keine Untertitel geben.
//
// Die **Leiter** bleibt davon unberührt. Sie ist eine eigene Frage
// und wird weiterhin nur bei „automatisch" und ohne Umpacken
// bestellt — beim Umpacken gibt es nur eine Fassung, nämlich die
// Datei selbst.
if transcode.prefersAdaptive, !transcode.prefersRemux {
adresse = Self.adaptive(adresse)
} else {
adresse = Self.masterPlaylist(adresse)
}
return PlaybackTarget(
url: adresse,
sessionID: info.playSessionID,
versionID: source?.id ?? sourceID
)
}
public func endPlayback(sessionID: String) async {
// Ohne dieses Signal rechnet der Transcoder weiter, bis der Server von
// selbst merkt, dass niemand mehr Segmente abholt. Bei direkter
// Wiedergabe gibt es nichts anzuhalten — der Aufruf läuft dann ins
// Leere, und das ist billiger als sich zu merken, welcher Fall es war.
_ = try? await client.send(JellyfinRequest(
method: .delete,
path: "/Videos/ActiveEncodings",
query: [
URLQueryItem(name: "deviceId", value: client.deviceID),
URLQueryItem(name: "playSessionId", value: sessionID),
],
timeout: .seconds(5)
))
}
public func reportProgress(_ report: PlaybackReport) async {
var fields: [String: Any] = [
"ItemId": report.itemID,
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
"IsPaused": report.state == .paused,
"CanSeek": true,
]
if let sessionID = report.sessionID { fields["PlaySessionId"] = sessionID }
if let versionID = report.versionID { fields["MediaSourceId"] = versionID }
// Erster und letzter Bericht gehen an eigene Endpunkte. Ohne den
// ersten erscheint der Titel nirgends als „läuft gerade"; ohne den
// letzten bleibt er für immer dort stehen. Ein Fortschritt mit
// derselben Zahl täte weder das eine noch das andere.
let path = switch report.state {
case .started: "/Sessions/Playing"
case .stopped: "/Sessions/Playing/Stopped"
default: "/Sessions/Playing/Progress"
}
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
_ = try? await client.send(JellyfinRequest(
method: .post,
path: path,
body: body,
timeout: .seconds(10)
))
}
// MARK: - Hilfen
/// Nicht `private`: Live-TV in `JellyfinBackend+LiveTV` fragt denselben
/// Weg. Ein Sender ist für Jellyfin ein Eintrag wie jeder andere, und
/// eine zweite Fassung dieser Abfrage wäre eine Kopie, die auseinanderläuft.
func playbackInfo(
itemID: String,
sourceID: String,
transcode: TranscodeSettings,
audioStreamIndex: Int? = nil,
subtitleStreamIndex: Int? = nil,
remux: Bool = false
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
// Jellyfin rechnet in Bit, die App in Kilobit.
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
// **Umpacken: dieselben Ströme, andere Schachtel.**
//
// Die Codecs stehen genau so breit wie das, was AVPlayer wirklich
// kann: trifft der Server eine Übereinstimmung, **kopiert** er den
// Strom, statt ihn neu zu rechnen. Genau dafür ist der Weg da — ein
// Matroska-Rip mit HEVC und AC3 kommt so bei AVPlayer an, mit
// Bild-in-Bild und AirPlay, ohne dass ein Bild angefasst wurde.
//
// **Und doch als HLS, obwohl eine fortlaufende Datei schöner wäre.**
// Der Versuch mit `Protocol: http` ist am Gerät gescheitert:
// AVPlayer verlangt bei einer fortlaufenden Datei Bereichsanfragen —
// er will hineinspringen können. Eine MP4, die ffmpeg gerade erst
// erzeugt, kann das nicht: sie hat weder Länge noch ein
// Inhaltsverzeichnis am Anfang. Die Antwort war
// `AVErrorServerIncorrectlyConfigured` (-11850), und zwar sofort.
//
// Damit bleibt vom ursprünglichen Ziel das Umpacken, nicht das weite
// Vorausladen. Das ist immer noch der grössere Teil des Gewinns.
//
// `Container: mp4` statt `ts`: als fragmentierte MP4 trägt HLS auch
// HEVC. In MPEG-TS wäre dafür eine Neukodierung nötig — also genau
// das, was hier vermieden werden soll.
let umpacken: [String: Any] = [
"Type": "Video",
"Container": "mp4",
"Protocol": "hls",
"VideoCodec": "h264,hevc",
"AudioCodec": "aac,ac3,eac3,mp3",
"Context": "Streaming",
"MaxAudioChannels": "8",
"MinSegments": 1,
]
let profile: [String: Any] = [
"MaxStreamingBitrate": bitsPerSecond,
// Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits
// entschieden, dass umgerechnet werden soll — dem Server die Wahl
// zu lassen hieße, die Entscheidung zweimal zu treffen.
"DirectPlayProfiles": [],
"TranscodingProfiles": remux ? [umpacken] : [[
"Type": "Video",
"Container": "ts",
"Protocol": "hls",
"VideoCodec": "h264",
"AudioCodec": "aac,mp3",
"Context": "Streaming",
"MaxAudioChannels": "2",
"MinSegments": 1,
"BreakOnNonKeyFrames": true,
]],
"SubtitleProfiles": [
["Format": "vtt", "Method": "Hls"],
["Format": "srt", "Method": "External"],
],
]
let body: Data
do {
body = try JSONSerialization.data(withJSONObject: ["DeviceProfile": profile])
} catch {
throw .decoding(description: String(describing: error))
}
return try await client.send(
JellyfinRequest(
method: .post,
path: "/Items/\(itemID)/PlaybackInfo",
query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "mediaSourceId", value: sourceID),
URLQueryItem(name: "maxStreamingBitrate", value: String(bitsPerSecond)),
// **Kein** `startTimeTicks`. Es gäbe zwei Stellen, die
// über den Startpunkt entscheiden: der Server ließe den
// Stream dort beginnen, und der Player spränge anschließend
// noch einmal um dieselbe Strecke vor — bei einundzwanzig
// Minuten Fortschritt landete man bei zweiundvierzig.
// Gesprungen wird an genau einer Stelle, und das ist der
// Player.
// **Die Tonspur ausdrücklich nennen.** Ohne sie wählt
// der Server, und bei einem umgerechneten Stream kommt
// danach nur noch diese eine an — dann ist im Player
// nichts mehr umzustellen. Die Sprachwahl muss hier
// stattfinden oder gar nicht.
URLQueryItem(name: "enableDirectPlay", value: "false"),
URLQueryItem(name: "enableDirectStream", value: "false"),
URLQueryItem(name: "enableTranscoding", value: "true"),
] + (audioStreamIndex.map {
[URLQueryItem(name: "audioStreamIndex", value: String($0))]
} ?? []) + (subtitleStreamIndex.map {
[URLQueryItem(name: "subtitleStreamIndex", value: String($0))]
} ?? []),
body: body
),
as: JellyfinPlaybackInfo.self
)
}
/// Hängt die gewünschte Bildhöhe an die Adresse des Servers.
///
/// Die Bitrate versteht Jellyfin über das Geräteprofil, die Bildhöhe nicht
/// ohne Weiteres. Sie wird deshalb nachträglich gesetzt — und eine bereits
/// vorhandene Angabe ersetzt, statt eine zweite danebenzustellen: bei zwei
/// gleichnamigen Werten gilt beim Server der erste, und das wäre dann
/// gerade nicht der gewählte.
///
/// `resolution` kommt in der Form „1280x720"; gebraucht wird die zweite
/// Zahl.
static func limiting(height resolution: String?, in url: URL) -> URL {
guard let resolution,
let height = resolution.split(separator: "x").last.flatMap({ Int($0) }),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return url }
var items = (components.queryItems ?? []).filter {
let name = $0.name.lowercased()
return name != "maxheight" && name != "maxwidth"
}
items.append(URLQueryItem(name: "maxHeight", value: String(height)))
components.queryItems = items
return components.url ?? url
}
/// Bestellt eine **Leiter** statt einer einzelnen Stufe.
///
/// Zwei Handgriffe, und beide sind nötig:
///
/// **`main.m3u8` wird zu `master.m3u8`.** Die eine ist die Liste der
/// Schnipsel *einer* Stufe, die andere die Liste der Stufen. Nur auf der
/// zweiten kann der Parameter überhaupt wirken — auf der ersten gibt es
/// keine Varianten, über die zu entscheiden wäre. Heißt die Adresse schon
/// `master.m3u8`, bleibt sie.
///
/// **Der Parameter wird gesetzt, nicht danebengestellt.** Die
/// Voreinstellung im Server ist `false`; ohne ihn kommt in jedem Fall nur
/// eine Stufe. Und bei zwei gleichnamigen Werten gilt beim Server der
/// erste — dieselbe Falle wie bei `maxHeight` eine Funktion weiter oben.
///
/// **Was das nicht kann.** Jellyfin verweigert die Leiter im lokalen Netz
/// grundsätzlich, und zwar mit Absicht: „Within the local network this
/// will likely do more harm than good." Ein schwaches WLAN zählt dort mit.
/// Ebenso fällt sie weg, wenn Bild **oder** Ton nur kopiert statt neu
/// gerechnet werden. Ob am Ende wirklich mehrere Stufen ankommen, steht
/// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und
/// das ist die einzige belastbare Auskunft darüber.
static func adaptive(_ url: URL) -> URL {
guard var components = URLComponents(url: masterPlaylist(url), resolvingAgainstBaseURL: false)
else { return masterPlaylist(url) }
var items = (components.queryItems ?? []).filter {
$0.name.lowercased() != "enableadaptivebitratestreaming"
}
items.append(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true"))
components.queryItems = items
return components.url ?? url
}
/// Von der Liste **einer** Stufe zur Liste **aller** Spuren.
///
/// **Der eigene Handgriff, und der wichtigere.** `main.m3u8` ist eine
/// Media Playlist: eine Liste von Schnipseln, sonst nichts. `master.m3u8`
/// ist eine Master Playlist, und nur dort darf nach RFC 8216 ein
/// `#EXT-X-MEDIA` stehen — also die Zeile, mit der ein Server eine
/// **Untertitelspur** ankündigt.
///
/// Das heisst: auf `main.m3u8` kann es strukturell keine Untertitel
/// geben, ganz gleich was bestellt wurde und was der Server liefern
/// wollte. Genau daran lag es, dass beim Umpacken und bei jeder von Hand
/// gewählten Stufe nie ein Untertitel erschien — dort blieb die Adresse
/// `main.m3u8`, weil der Wechsel an der Leiter hing.
///
/// Deshalb steht er jetzt für sich: **jeder HLS-Stream bekommt die
/// Master-Liste.** Ob darin mehrere Stufen stehen, ist eine andere Frage
/// — die beantwortet `enableAdaptiveBitrateStreaming`, und eine
/// Master-Liste mit einer einzigen Stufe ist völlig in Ordnung.
static func masterPlaylist(_ url: URL) -> URL {
guard url.lastPathComponent == "main.m3u8" else { return url }
return url.deletingLastPathComponent().appending(path: "master.m3u8")
}
}
/// Was der Server für eine Wiedergabe vorbereitet hat.
public struct JellyfinPlaybackInfo: Decodable, Sendable {
public let mediaSources: [JellyfinMediaSource]?
public let playSessionID: String?
private enum CodingKeys: String, CodingKey {
case mediaSources = "MediaSources"
case playSessionID = "PlaySessionId"
}
}