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:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user