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>
250 lines
9.9 KiB
Swift
250 lines
9.9 KiB
Swift
import Foundation
|
|
import Network
|
|
import OSLog
|
|
|
|
/// Ein winziger HTTP-Server, von dem AVPlayer seinen Film holt.
|
|
///
|
|
/// **Warum überhaupt ein Server.** Apple lässt uns bei HLS nicht zwischen
|
|
/// Player und Netz: auf eine Anfrage nach einem Schnipsel nimmt AVPlayer von
|
|
/// uns nur eine Weiterleitung an, keine Daten. Der einzige Weg, ihm etwas
|
|
/// Eigenes zu liefern, ist, die Gegenstelle **zu sein**. Dann ist es keine
|
|
/// Einmischung mehr, sondern eine gewöhnliche Anfrage an eine gewöhnliche
|
|
/// Adresse — die zufällig auf demselben Gerät liegt.
|
|
///
|
|
/// **Nur auf diesem Gerät.** Der Lauscher hängt ausdrücklich an `127.0.0.1`,
|
|
/// nicht an allen Schnittstellen. Ein Server, der den Film ins Netz stellt,
|
|
/// wäre das Letzte, was eine Mediathek-App tun sollte — und in einem offenen
|
|
/// WLAN eine Einladung.
|
|
///
|
|
/// Der Port kommt vom System. Ein fester wäre beim zweiten Start belegt, und
|
|
/// zwei Wiedergaben gleichzeitig — Kachel und Vollbild beim Umschalten — gäbe
|
|
/// es dann nie zweimal.
|
|
public actor LocalStreamServer {
|
|
/// Der Pfad, unter dem Schnipsel ausgeliefert werden.
|
|
private static let segmentPrefix = "/s/"
|
|
/// Der Pfad der Abspielliste selbst.
|
|
private static let playlistPath = "/playlist.m3u8"
|
|
/// Der Pfad einer Stufe, die eine Ebene tiefer liegt.
|
|
///
|
|
/// Die Endung muss stimmen: AVPlayer entscheidet am Namen, ob er eine
|
|
/// Abspielliste oder Bild erwartet.
|
|
private static let levelPrefix = "/level.m3u8"
|
|
|
|
/// Wie eine Adresse aus der Abspielliste beim Server heisst.
|
|
///
|
|
/// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche
|
|
/// und Punkte darin nichts anrichten.
|
|
/// Wie eine Stufe aus der Stufenliste beim Server heisst.
|
|
///
|
|
/// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem
|
|
/// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins
|
|
/// Leere.
|
|
/// - Parameter prefetch: ob dahinter Bild liegt. Bei einer Nebenspur —
|
|
/// Untertitel oder zusätzlicher Ton — steht es auf `false`, sonst lüde
|
|
/// der Vorrat Untertitel statt Bild, und der Film bliebe stehen,
|
|
/// während die Anzeige reichlich Vorrat meldet.
|
|
public nonisolated static func localPlaylistPath(
|
|
for absolute: String,
|
|
prefetch: Bool
|
|
) -> String {
|
|
var teile = URLComponents()
|
|
teile.path = levelPrefix
|
|
teile.queryItems = [
|
|
URLQueryItem(name: "u", value: absolute),
|
|
URLQueryItem(name: "v", value: prefetch ? "1" : "0"),
|
|
]
|
|
return teile.string ?? levelPrefix
|
|
}
|
|
|
|
/// Wie ein Schnipsel beim Server heisst.
|
|
///
|
|
/// Die **vollständige** Adresse wandert in die Abfrage. Eine relative
|
|
/// gälte je nach Liste gegen eine andere Grundadresse — die
|
|
/// Untertitelspur liegt woanders als das Bild —, und wer das erst beim
|
|
/// Abholen auflöst, löst es irgendwann gegen die falsche auf.
|
|
public nonisolated static func localPath(for absolute: String) -> String {
|
|
var teile = URLComponents()
|
|
teile.path = segmentPrefix
|
|
teile.queryItems = [URLQueryItem(name: "u", value: absolute)]
|
|
return teile.string ?? segmentPrefix
|
|
}
|
|
|
|
private let cache: StreamCache
|
|
private let logger = Logger(subsystem: "app.halo", category: "LocalStreamServer")
|
|
private var listener: NWListener?
|
|
private var port: UInt16?
|
|
private var playlist: String?
|
|
|
|
public init(cache: StreamCache) {
|
|
self.cache = cache
|
|
}
|
|
|
|
// MARK: - Starten
|
|
|
|
/// Startet den Server und gibt die Adresse zurück, die der Player bekommt.
|
|
///
|
|
/// Wirft, wenn der Lauscher nicht hochkommt — dann muss der Aufrufer die
|
|
/// ursprüngliche Adresse verwenden. **Kein Ersatz für Wiedergabe:** lieber
|
|
/// ohne Vorrat spielen als gar nicht.
|
|
public func start(upstream: URL, headers: [String: String] = [:]) async throws -> URL {
|
|
playlist = try await cache.start(upstream: upstream, headers: headers)
|
|
|
|
let parameters = NWParameters.tcp
|
|
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
|
|
let lauscher = try NWListener(using: parameters)
|
|
|
|
lauscher.newConnectionHandler = { [weak self] verbindung in
|
|
Task { await self?.accept(verbindung) }
|
|
}
|
|
|
|
// Der Zustandsmelder kommt mehrfach und aus fremdem Zusammenhang.
|
|
// Eine Fortsetzung zweimal aufzulösen bringt das Programm zum
|
|
// Anhalten — deshalb ein Riegel, der das ausschliesst.
|
|
let vergeben: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
|
|
let riegel = OneShot()
|
|
lauscher.stateUpdateHandler = { zustand in
|
|
switch zustand {
|
|
case .ready:
|
|
guard riegel.claim() else { return }
|
|
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
|
|
case .failed(let fehler):
|
|
guard riegel.claim() else { return }
|
|
fortsetzen.resume(throwing: fehler)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
lauscher.start(queue: .global(qos: .userInitiated))
|
|
}
|
|
|
|
listener = lauscher
|
|
port = vergeben
|
|
guard vergeben > 0,
|
|
let adresse = URL(string: "http://127.0.0.1:\(vergeben)\(Self.playlistPath)")
|
|
else { throw URLError(.cannotConnectToHost) }
|
|
return adresse
|
|
}
|
|
|
|
public func stop() {
|
|
listener?.cancel()
|
|
listener = nil
|
|
Task { await cache.teardown() }
|
|
}
|
|
|
|
// MARK: - Bedienen
|
|
|
|
private func accept(_ verbindung: NWConnection) {
|
|
verbindung.start(queue: .global(qos: .userInitiated))
|
|
receive(on: verbindung, buffer: Data())
|
|
}
|
|
|
|
private nonisolated func receive(on verbindung: NWConnection, buffer: Data) {
|
|
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
|
|
[weak self] daten, _, abgeschlossen, fehler in
|
|
guard let self else { return }
|
|
var gesammelt = buffer
|
|
if let daten { gesammelt.append(daten) }
|
|
|
|
// Der Kopf endet mit einer Leerzeile. Bis dahin sammeln — eine
|
|
// Anfrage kann auf mehrere Pakete verteilt ankommen, und wer das
|
|
// nicht abwartet, liest eine halbe Zeile als ganze Anfrage.
|
|
guard let kopfEnde = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
|
|
if fehler == nil, !abgeschlossen {
|
|
self.receive(on: verbindung, buffer: gesammelt)
|
|
} else {
|
|
verbindung.cancel()
|
|
}
|
|
return
|
|
}
|
|
|
|
let kopf = String(decoding: gesammelt[..<kopfEnde.lowerBound], as: UTF8.self)
|
|
Task { await self.respond(to: kopf, on: verbindung) }
|
|
}
|
|
}
|
|
|
|
private func respond(to kopf: String, on verbindung: NWConnection) async {
|
|
guard let anfrage = kopf.components(separatedBy: "\r\n").first,
|
|
let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first
|
|
else { return finish(verbindung, status: 400, body: Data()) }
|
|
|
|
if pfadTeil.hasPrefix(Self.levelPrefix) {
|
|
guard let teile = URLComponents(string: pfadTeil),
|
|
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value,
|
|
let inhalt = await cache.playlist(
|
|
at: adresse,
|
|
prefetch: teile.queryItems?.first { $0.name == "v" }?.value != "0"
|
|
)
|
|
else { return finish(verbindung, status: 404, body: Data()) }
|
|
|
|
return finish(
|
|
verbindung, status: 200, body: Data(inhalt.utf8),
|
|
type: "application/vnd.apple.mpegurl"
|
|
)
|
|
}
|
|
|
|
if pfadTeil.hasPrefix(Self.playlistPath) {
|
|
let inhalt = Data((playlist ?? "").utf8)
|
|
return finish(
|
|
verbindung, status: 200, body: inhalt,
|
|
type: "application/vnd.apple.mpegurl"
|
|
)
|
|
}
|
|
|
|
guard pfadTeil.hasPrefix(Self.segmentPrefix),
|
|
let teile = URLComponents(string: pfadTeil),
|
|
let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value
|
|
else { return finish(verbindung, status: 404, body: Data()) }
|
|
|
|
guard let schnipsel = await cache.data(for: adresse) else {
|
|
return finish(verbindung, status: 504, body: Data())
|
|
}
|
|
// **Die Sorte, die wirklich ankam.** Hier stand fest „video/mp4", für
|
|
// jeden Schnipsel. Ein WebVTT-Körper, so angekündigt, geht bei
|
|
// AVFoundation an den falschen Parser und ergibt lautlos keine
|
|
// Einblendung — die Spur steht im Blatt, ist ausgewählt, und bleibt
|
|
// leer. Genau daran lag es.
|
|
finish(verbindung, status: 200, body: schnipsel.data, type: schnipsel.contentType)
|
|
}
|
|
|
|
private nonisolated func finish(
|
|
_ verbindung: NWConnection,
|
|
status: Int,
|
|
body: Data,
|
|
type: String = "application/octet-stream"
|
|
) {
|
|
let kopf = """
|
|
HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")\r
|
|
Content-Type: \(type)\r
|
|
Content-Length: \(body.count)\r
|
|
Accept-Ranges: none\r
|
|
Connection: close\r
|
|
\r
|
|
|
|
"""
|
|
var antwort = Data(kopf.utf8)
|
|
antwort.append(body)
|
|
verbindung.send(content: antwort, completion: .contentProcessed { _ in
|
|
verbindung.cancel()
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
/// Ein Riegel, der genau einmal aufgeht.
|
|
///
|
|
/// Für Rückrufe, die mehrfach kommen, aber nur einmal beantwortet werden
|
|
/// dürfen. Eine `var` im Abschluss täte es nicht: sie wird aus fremdem
|
|
/// Zusammenhang berührt, und Swift lässt das zu Recht nicht durchgehen.
|
|
private final class OneShot: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var used = false
|
|
|
|
func claim() -> Bool {
|
|
lock.withLock {
|
|
guard !used else { return false }
|
|
used = true
|
|
return true
|
|
}
|
|
}
|
|
}
|