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>
343 lines
16 KiB
Swift
343 lines
16 KiB
Swift
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"
|
|
}
|
|
}
|