Halo heisst ab hier Flaro — die Marke stand im Weg

Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 deletions
@@ -0,0 +1,536 @@
// **Nur für den Rechner, auf dem entwickelt wird.**
//
// Das hier ist ein Kommandozeilenwerkzeug: es fragt nach einem Passwort über
// `getpass`, löst Rechnernamen über `Host` auf und druckt in ein Terminal.
// Nichts davon gibt es auf iOS, und auf tvOS erst recht nicht — die App
// hängt auch nicht daran. Ohne diese Bedingung scheitert aber jeder Versuch,
// **das ganze Paket** für eine andere Plattform zu übersetzen, an einem
// Werkzeug, das dort nie laufen sollte. Das kostete eine halbe Stunde
// Fehlersuche an der falschen Stelle.
#if os(macOS)
import Foundation
import JellyfinKit
import MediaKit
// Die Frage, für die dieser Teil des Werkzeugs gebaut ist:
//
// Liefert der Server eine HLS-Liste mit **mehreren** Stufen — oder mit
// einer?
//
// Davon hängt alles ab. AVPlayer kann nur zwischen Varianten umschalten, die
// in der Liste stehen; bei einer gibt es nichts zu wechseln, und jedes
// Nachregeln greift ins Leere. Bisher war das eine Annahme, und zwei
// Kommentare im Code haben sie als Tatsache ausgegeben.
//
// Geprüft wird über HTTP, nicht über einen Build aufs Gerät. Der Token bleibt
// im Schlüsselbund; gedruckt werden Stufen und Zahlen, nie eine vollständige
// Adresse.
/// Wie der Server die Herkunft dieser Anfrage einordnet.
///
/// `isInNetwork` ist genau die Größe, an der Jellyfins ABR-Sperre hängt.
struct Endpunktlage: Decodable {
let isLocal: Bool
let isInNetwork: Bool
private enum CodingKeys: String, CodingKey {
case isLocal = "IsLocal"
case isInNetwork = "IsInNetwork"
}
}
/// Welche Fassung der Server hat.
struct Serverfassung: Decodable {
let version: String?
private enum CodingKeys: String, CodingKey { case version = "Version" }
}
/// Die Netzwerkeinstellungen des Servers — nur der Teil, der hier zählt.
struct Netzwerkeinstellung: Decodable {
let knownProxies: [String]?
private enum CodingKeys: String, CodingKey {
case knownProxies = "KnownProxies"
}
}
/// Was in einer Playlist steht.
struct Leiter {
var stufen: [(bandbreite: Int, auflösung: String?)] = []
var istMaster = false
var zeilen = 0
var beschreibung: String {
guard istMaster else {
return " Keine Master-Playlist (\(zeilen) Zeilen) — das ist eine Liste von Schnipseln, keine von Stufen."
}
guard !stufen.isEmpty else {
return " Master-Playlist ohne eine einzige Stufe."
}
var text = " \(stufen.count) Stufe\(stufen.count == 1 ? "" : "n"):\n"
for stufe in stufen.sorted(by: { $0.bandbreite < $1.bandbreite }) {
let mbit = String(format: "%.1f", Double(stufe.bandbreite) / 1_000_000)
text += " \(mbit) Mbit/s"
if let auflösung = stufe.auflösung { text += " · \(auflösung)" }
text += "\n"
}
if stufen.count == 1 {
text += " → Eine Stufe. Es gibt nichts, worauf der Player ausweichen könnte.\n"
}
return text
}
}
/// Liest eine Playlist und zählt, was drinsteht.
func leiter(in text: String) -> Leiter {
var ergebnis = Leiter()
let zeilen = text.split(separator: "\n", omittingEmptySubsequences: false)
ergebnis.zeilen = zeilen.count
for zeile in zeilen where zeile.hasPrefix("#EXT-X-STREAM-INF:") {
ergebnis.istMaster = true
let attribute = zeile.dropFirst("#EXT-X-STREAM-INF:".count)
func wert(_ name: String) -> String? {
// Reicht für BANDWIDTH und RESOLUTION: beide sind unquotiert.
guard let bereich = attribute.range(of: "\(name)=") else { return nil }
let rest = attribute[bereich.upperBound...]
return String(rest.prefix { $0 != "," })
}
guard let roh = wert("BANDWIDTH"), let bandbreite = Int(roh) else { continue }
ergebnis.stufen.append((bandbreite, wert("RESOLUTION")))
}
return ergebnis
}
/// Holt eine Playlist und beurteilt sie.
func prüfePlaylist(_ url: URL, kopfzeilen: [String: String] = [:]) async -> String {
var request = URLRequest(url: url)
for (name, wert) in kopfzeilen { request.setValue(wert, forHTTPHeaderField: name) }
do {
let (daten, antwort) = try await URLSession.shared.data(for: request)
let status = (antwort as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else {
return " HTTP \(status) — keine Playlist bekommen."
}
let text = String(decoding: daten, as: UTF8.self)
let gelesen = leiter(in: text)
// **Bei einer Stufe den Wortlaut zeigen.** Ab hier hilft keine
// Zusammenfassung mehr: was fehlt, sieht man nur im Original.
guard gelesen.stufen.count <= 1 else { return gelesen.beschreibung }
return gelesen.beschreibung + "\n ——— Wortlaut ———\n"
+ text.split(separator: "\n").map { " " + $0 }.joined(separator: "\n")
} catch {
return " Nicht erreichbar: \(error.localizedDescription)"
}
}
/// Setzt einen Abfragewert — vorhandene gleichen Namens werden **ersetzt**.
///
/// Dieselbe Falle wie bei `maxHeight` im Backend: bei zwei gleichnamigen
/// Werten gilt beim Server der erste, und ein danebengestellter zweiter
/// bewirkt nichts.
func setzend(_ name: String, auf wert: String, in url: URL) -> URL {
guard var teile = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return url }
var werte = (teile.queryItems ?? []).filter { $0.name.lowercased() != name.lowercased() }
werte.append(URLQueryItem(name: name, value: wert))
teile.queryItems = werte
return teile.url ?? url
}
/// Macht aus einer Varianten-Playlist die Master-Playlist.
///
/// `main.m3u8` ist die Liste der **Schnipsel** einer Stufe, `master.m3u8` die
/// Liste der **Stufen**. Nur auf letzterer kann der Parameter für mehrere
/// Varianten überhaupt wirken.
func alsMaster(_ url: URL) -> URL {
guard url.lastPathComponent == "main.m3u8" else { return url }
return url.deletingLastPathComponent().appending(path: "master.m3u8")
}
// MARK: - Jellyfin
/// Fragt einen Jellyfin-Server, welche Playlist er für einen Titel ausgibt.
///
/// Das Passwort wird hier eingetippt und nirgends aufbewahrt; der Token bleibt
/// in diesem Prozess. Gedruckt wird der **Pfad** der Adresse, nie die
/// vollständige mit Schlüssel.
func jellyfinLeiter(host: String) async {
guard let basis = URL(string: host.contains("://") ? host : "http://\(host)") else {
print("Keine brauchbare Adresse: \(host)")
return
}
print("Benutzername: ", terminator: "")
guard let name = readLine(), !name.isEmpty else { return }
// Ohne Echo, damit nichts im Bild und nichts im Verlauf steht.
guard let passwort = String(validatingCString: getpass("Passwort: ")), !passwort.isEmpty else {
return
}
let identity = JellyfinIdentity(
client: "Flaro-Probe",
device: "Mac",
deviceID: "flaro-probe-" + UUID().uuidString,
version: "1.0"
)
let client = JellyfinClient(baseURL: basis, identity: identity)
let konto: JellyfinAccount
do {
konto = try await JellyfinAuthenticator(client: client).signIn(
userName: name,
password: passwort
)
} catch {
print("Anmeldung gescheitert: \(error)")
return
}
print("Angemeldet als \(konto.userName).")
// Die Fassung des Servers. Ohne sie lese ich womöglich den Quelltext
// einer anderen — und suche den Fehler an einer Stelle, die es dort
// gar nicht gibt.
if let info = try? await client.send(
JellyfinRequest(path: "/System/Info/Public"),
as: Serverfassung.self
) {
print("Jellyfin \(info.version ?? "unbekannt")")
}
print("")
let backend = JellyfinBackend(client: client, account: konto, serverName: "Probe")
// **Die Frage, die alles andere erklärt, direkt an den Server.**
//
// `/System/Endpoint` beantwortet `IsInNetwork` mit demselben Aufruf,
// mit dem auch die ABR-Sperre entscheidet:
// `_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())`.
// Damit muss niemand mehr raten, ob „von zuhause" der Grund ist.
let lage = try? await client.send(
JellyfinRequest(path: "/System/Endpoint"),
as: Endpunktlage.self
)
if let lage {
// Beide Werte ausdrücklich: sie unterscheiden zwei Lagen mit ganz
// verschiedenen Ursachen.
print("Der Server sieht: IsLocal=\(lage.isLocal), IsInNetwork=\(lage.isInNetwork)")
if lage.isLocal {
print(" → IsLocal heißt: die Anfrage kommt vom Rechner selbst,")
print(" also über 127.0.0.1. Dann steht der Proxy auf demselben")
print(" Host, und der Eintrag unter Bekannte Proxys muss 127.0.0.1")
print(" lauten (bzw. die Adresse im Docker-Netz), nicht 192.168.x.")
}
// **Was der Server tatsächlich eingetragen hat.**
//
// Ein Eintrag, der gespeichert, aber nicht übernommen wurde, sieht
// von außen genauso aus wie gar keiner. Hier steht, was gilt.
if let netz = try? await client.send(
JellyfinRequest(path: "/System/Configuration/network"),
as: Netzwerkeinstellung.self
) {
let proxys = netz.knownProxies ?? []
if proxys.isEmpty {
print(" Bekannte Proxys: KEINE eingetragen.")
} else {
print(" Bekannte Proxys: \(proxys.joined(separator: ", "))")
}
}
if lage.isInNetwork {
print("Der Server hält diese Verbindung für LOKAL.")
print(" → Eine Leiter wird er hier grundsätzlich nicht liefern,")
print(" ganz gleich was wir anfragen. Das Folgende sagt damit")
print(" nichts darüber, was von unterwegs käme.")
} else {
print("Der Server hält diese Verbindung für AUSSERHALB.")
print(" → Hier ist eine Leiter möglich. Kommt trotzdem nur eine")
print(" Stufe, liegt es an etwas anderem.")
}
print("")
}
// Irgendeinen Film. Es geht um die Form der Antwort, nicht um den Inhalt.
guard let mediathek = (try? await backend.libraries())?.first(where: { $0.kind == .movie }),
let kachel = try? await backend.items(
inLibrary: mediathek,
query: MediaQuery(),
offset: 0,
limit: 1
).items.first
else {
print("Keine Filmmediathek mit einem Titel gefunden.")
return
}
// Die Kachel aus der Liste trägt keine Dateiangaben — die kommen erst mit
// den Einzelheiten. Ohne sie lässt sich keine Wiedergabe vorbereiten.
let film = (try? await backend.item(id: kachel.id)) ?? kachel
guard let fassung = film.preferredVersion, let datei = fassung.files.first else {
print("Zu „\(film.title)“ meldet der Server keine Datei.")
return
}
print("Titel: \(film.title)\n")
guard let ziel = try? await backend.preparePlayback(PlaybackIntent(
itemID: film.id,
versionIndex: 0,
file: datei,
// Ausdrücklich umrechnen lassen: nur dann gibt es überhaupt eine
// Playlist, über die sich die Frage stellen lässt.
transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil)
)) else {
print("Der Server gibt für diesen Titel keine Transcode-Adresse her.")
return
}
let kopf = ["X-Emby-Token": konto.token]
let adresse = ziel.url
print("Was der Server von sich aus liefert:")
print(" Pfad: \(adresse.path)")
let abfrage = URLComponents(url: adresse, resolvingAgainstBaseURL: true)?.queryItems ?? []
print(" Abfragewerte: \(abfrage.map(\.name).sorted().joined(separator: ", "))")
// **Die Werte nur von den harmlosen Schlüsseln.** Schlüssel, Kennungen
// und Sitzungsnummern bleiben ungedruckt — aber ob Bild und Ton
// umgerechnet oder nur kopiert werden, entscheidet mit darüber, ob es
// überhaupt eine Leiter geben kann, und steht nirgends sonst.
let unbedenklich = ["VideoCodec", "AudioCodec", "VideoBitrate", "AudioBitrate",
"TranscodeReasons", "SegmentContainer", "maxHeight"]
let gezeigt = abfrage
.filter { unbedenklich.contains($0.name) }
.map { "\($0.name)=\($0.value ?? "")" }
.sorted()
if !gezeigt.isEmpty { print(" Davon: \(gezeigt.joined(separator: ", "))") }
if abfrage.contains(where: { $0.name == "AudioCodec" && $0.value?.lowercased() == "copy" })
|| abfrage.contains(where: { $0.name == "VideoCodec" && $0.value?.lowercased() == "copy" }) {
print("")
print(" ACHTUNG: Hier wird kopiert statt gerechnet. Jellyfin liefert")
print(" dann grundsätzlich keine Leiter — auch von unterwegs nicht.")
}
print("")
print("1) Unverändert")
print(await prüfePlaylist(adresse, kopfzeilen: kopf))
let master = alsMaster(adresse)
if master != adresse {
print("2) Als master.m3u8")
print(await prüfePlaylist(master, kopfzeilen: kopf))
}
print("3) Als master.m3u8 mit enableAdaptiveBitrateStreaming=true")
let mitABR = await prüfePlaylist(
setzend("enableAdaptiveBitrateStreaming", auf: "true", in: master),
kopfzeilen: kopf
)
print(mitABR)
if mitABR.contains("1 Stufe:") {
erklärungWarumKeineLeiter()
await prüfeSelbstgebauteLeiter(master: master, kopfzeilen: kopf)
}
// Aufräumen: die Aushandlung hat am Server eine Sitzung angelegt, und
// ohne dieses Signal rechnet der Transcoder weiter, obwohl niemand
// zusieht. Ein Messwerkzeug, das Arbeit hinterlässt, ist ein schlechtes.
if let sitzung = ziel.sessionID {
await backend.endPlayback(sessionID: sitzung)
}
print("")
print("Transcode-Sitzung beendet.")
}
/// Sagt, woran es liegen kann — aus Jellyfins eigenem Quelltext.
///
/// `DynamicHlsHelper.EnableAdaptiveBitrateStreaming` prüft der Reihe nach,
/// und die erste Hürde ist die überraschendste.
func erklärungWarumKeineLeiter() {
print("")
print(" Warum nur eine Stufe kommt — aus Jellyfins Quelltext,")
print(" `DynamicHlsHelper.EnableAdaptiveBitrateStreaming`, der Reihe nach:")
print("")
print(" 1. Im lokalen Netz liefert Jellyfin grundsätzlich keine Leiter.")
print(" Wörtlich im Quelltext: „Within the local network this will")
print(" likely do more harm than good.\" Das gilt auch für ein")
print(" schwaches WLAN — unterschieden wird da nicht.")
print(" 2. Ohne den Parameter keine Leiter; im Server steht er auf false.")
print(" 3. Bei einem laufenden Sender nicht.")
print(" 4. Wird Bild ODER Ton nur kopiert statt neu gerechnet, nicht.")
print(" Das trifft oft zu, wenn der Ton schon AAC ist.")
print("")
print(" Kam dieser Aufruf von zuhause, ist Punkt 1 die Erklärung — und dann")
print(" sagt die Messung nichts darüber, was von unterwegs käme.")
}
/// Prüft, ob wir uns die Leiter **selbst** bauen könnten — und ob das etwas
/// brächte.
///
/// Eine Master-Playlist ist nur ein Menü: eine Liste von Adressen. Jellyfins
/// eigene Leiter bestünde aus derselben `main.m3u8`-Adresse, mehrfach, mit
/// anderem `VideoBitrate`. Schreiben wir das Menü selbst, brauchen wir seine
/// Zustimmung nicht.
///
/// **Die Playlist zu bekommen beweist aber noch nichts.** Sie ist Text und
/// kostet nichts; die Datenrate steckt in den Schnipseln. Entscheidend ist
/// deshalb allein, ob die Schnipsel bei kleinerer Zahl auch kleiner werden —
/// oder ob der Server stur denselben Stream ausliefert, weil die Sitzung schon
/// läuft. Deshalb wird hier gewogen, nicht gezählt.
func prüfeSelbstgebauteLeiter(master: URL, kopfzeilen: [String: String]) async {
print("")
print("=== Ginge eine selbstgebaute Leiter — und brächte sie etwas? ===")
guard let (daten, _) = try? await URLSession.shared.data(for: anfrage(master, kopfzeilen)),
let zeile = String(decoding: daten, as: UTF8.self)
.split(separator: "\n")
.first(where: { $0.hasPrefix("main.m3u8") }),
let unter = URL(string: String(zeile), relativeTo: master)?.absoluteURL
else {
print(" Keine Unter-Playlist im Menü gefunden.")
return
}
let versuche: [(String, Int?, Int?)] = [
("wie gehabt", nil, nil),
("halbe Bitrate", 3_000_000, nil),
("ein Viertel", 1_500_000, nil),
("ein Viertel + 480p", 1_500_000, 480),
]
for (name, bitrate, höhe) in versuche {
var ziel = unter
if let bitrate { ziel = setzend("VideoBitrate", auf: String(bitrate), in: ziel) }
if let höhe { ziel = setzend("maxHeight", auf: String(höhe), in: ziel) }
print(" \(name): \(await wiegeErstenSchnipsel(ziel, kopfzeilen: kopfzeilen))")
}
print("")
print(" Werden die Schnipsel kleiner, trägt die Idee. Bleiben sie gleich")
print(" gross, rechnet der Server trotz anderer Zahl denselben Stream —")
print(" dann bringt ein selbstgebautes Menü nichts.")
}
/// Holt die Unter-Playlist, lädt den ersten Schnipsel und rechnet aus, was das
/// an Datenrate bedeutet.
private func wiegeErstenSchnipsel(_ playlist: URL, kopfzeilen: [String: String]) async -> String {
guard let (daten, antwort) = try? await URLSession.shared.data(for: anfrage(playlist, kopfzeilen)) else {
return "nicht erreichbar"
}
let status = (antwort as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else { return "HTTP \(status)" }
let zeilen = String(decoding: daten, as: UTF8.self).split(separator: "\n")
guard let dauerZeile = zeilen.firstIndex(where: { $0.hasPrefix("#EXTINF") }),
zeilen.indices.contains(dauerZeile + 1),
let schnipselURL = URL(string: String(zeilen[dauerZeile + 1]), relativeTo: playlist)?.absoluteURL
else { return "HTTP \(status), aber kein Schnipsel darin" }
let dauer = Double(
zeilen[dauerZeile]
.dropFirst("#EXTINF:".count)
.prefix { $0 != "," }
) ?? 0
guard let (rohdaten, schnipselAntwort) = try? await URLSession.shared.data(
for: anfrage(schnipselURL, kopfzeilen)
) else { return "Playlist ja, Schnipsel nicht erreichbar" }
let schnipselStatus = (schnipselAntwort as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(schnipselStatus) else {
return "Playlist ja, Schnipsel HTTP \(schnipselStatus)"
}
let kilobyte = Double(rohdaten.count) / 1024
guard dauer > 0 else { return String(format: "%.0f kB", kilobyte) }
let kilobit = Double(rohdaten.count) * 8 / 1000 / dauer
return String(format: "%.0f kB für %.1f s → %.0f kbit/s", kilobyte, dauer, kilobit)
}
private func anfrage(_ url: URL, _ kopfzeilen: [String: String]) -> URLRequest {
var request = URLRequest(url: url)
for (name, wert) in kopfzeilen { request.setValue(wert, forHTTPHeaderField: name) }
return request
}
/// Zeigt, was der Server über die Tonspuren eines Titels wirklich sagt.
///
/// **Der Grund:** Die Sprachwahl vergleicht Kürzel, und Kürzel lügen. Ob bei
/// einer Datei der Titel das rettet — „eng" im Kürzel, „Deutsch" im Namen —,
/// sieht man nur, wenn man beides nebeneinander legt. Alles andere wäre
/// geraten.
func zeigeSpuren(host: String, suche: String) async {
guard let basis = URL(string: host.contains("://") ? host : "http://\(host)") else { return }
print("Benutzername: ", terminator: "")
guard let name = readLine(), !name.isEmpty else { return }
guard let passwort = String(validatingCString: getpass("Passwort: ")), !passwort.isEmpty else { return }
let identity = JellyfinIdentity(
client: "Flaro-Probe",
device: "Mac",
deviceID: "flaro-probe-" + UUID().uuidString,
version: "1.0"
)
let client = JellyfinClient(baseURL: basis, identity: identity)
guard let konto = try? await JellyfinAuthenticator(client: client).signIn(
userName: name,
password: passwort
) else {
print("Anmeldung gescheitert.")
return
}
print("Angemeldet als \(konto.userName).\n")
// Suchen, damit man nicht mit Kennungen hantieren muss.
let treffer = try? await client.send(
JellyfinRequest(path: "/Users/\(konto.userID)/Items", query: [
URLQueryItem(name: "searchTerm", value: suche),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Episode"),
URLQueryItem(name: "Limit", value: "5"),
URLQueryItem(name: "Fields", value: "MediaSources"),
]),
as: JellyfinItemPage.self
)
guard let gefunden = treffer?.items, !gefunden.isEmpty else {
print("Nichts gefunden zu „\(suche)“.")
return
}
for eintrag in gefunden {
print("── \(eintrag.name ?? "—")")
let quellen = eintrag.mediaSources ?? []
guard let streams = quellen.first?.mediaStreams else {
print(" keine Spurangaben\n")
continue
}
for spur in streams where spur.type == "Audio" {
let kürzel = spur.language ?? "—"
let name = spur.title ?? "—"
var merkmale: [String] = []
if spur.isDefault == true { merkmale.append("Standard") }
if spur.isForced == true { merkmale.append("erzwungen") }
if spur.isHearingImpaired == true { merkmale.append("hörgeschädigt") }
if spur.isOriginal == true { merkmale.append("Original") }
print(" [\(spur.index ?? -1)] Kürzel \(kürzel)")
print(" Name in der Datei: \(name)")
print(" Wie Jellyfin es zeigt: \(spur.displayTitle ?? "—")")
print(" \(spur.codec ?? "—"), \(spur.channels ?? 0) Kanäle"
+ (merkmale.isEmpty ? "" : ", " + merkmale.joined(separator: ", ")))
}
print("")
}
print("Zu lesen ist das so: stimmt das Kürzel nicht mit dem Namen überein,")
print("lügt die Datei — und dann hilft nur der Name oder das Merken.")
}
#endif
@@ -0,0 +1,58 @@
// **Nur für den Rechner, auf dem entwickelt wird.**
//
// Das hier ist ein Kommandozeilenwerkzeug: es fragt nach einem Passwort über
// `getpass` und druckt in ein Terminal. Nichts davon gibt es auf iOS, und auf
// tvOS erst recht nicht — die App hängt auch nicht daran. Ohne diese Bedingung
// scheitert aber jeder Versuch, **das ganze Paket** für eine andere Plattform
// zu übersetzen, an einem Werkzeug, das dort nie laufen sollte. Das kostete
// eine halbe Stunde Fehlersuche an der falschen Stelle.
#if os(macOS)
import Foundation
import MediaKit
// Ein Werkzeug für die Fragen, die reines HTTP sind.
//
// Der Grund, dass es das gibt: eine Vermutung über die Schnittstelle eines
// Servers ging bisher als TestFlight-Build zum Nutzer und kam einen halben Tag
// später als Screenshot zurück. Irgendwann war das Upload-Kontingent des Tages
// aufgebraucht — für eine Frage, die keine Oberfläche braucht. Hier dauert
// eine Runde Sekunden.
//
// Der Token wird über die übliche Anmeldung geholt und liegt im Schlüsselbund
// dieses Rechners. Er steht nirgends in der Ausgabe: gedruckt werden Pfade,
// Kennungen und Antworten, keine vollständigen Adressen.
if let index = CommandLine.arguments.firstIndex(of: "--playlist"),
let roh = CommandLine.arguments.dropFirst(index + 1).first,
let url = URL(string: roh) {
print("=== \(url.lastPathComponent) ===")
print(await prüfePlaylist(url))
exit(0)
}
if let index = CommandLine.arguments.firstIndex(of: "--spuren"),
let host = CommandLine.arguments.dropFirst(index + 1).first,
let suche = CommandLine.arguments.dropFirst(index + 2).first {
await zeigeSpuren(host: host, suche: suche)
exit(0)
}
if let index = CommandLine.arguments.firstIndex(of: "--jellyfin"),
let host = CommandLine.arguments.dropFirst(index + 1).first {
await jellyfinLeiter(host: host)
exit(0)
}
// Kein Modus erkannt. Ohne diesen Hinweis endete das Werkzeug wortlos — und
// ein Tippfehler im Argument sähe aus wie ein Server, der nichts liefert.
print("""
FlaroProbe — fragt Server über reines HTTP, ohne Oberfläche und ohne Gerät.
--playlist <url> Eine HLS-Leiter prüfen: Varianten, Tonspuren,
erster Schnipsel
--jellyfin <host> Die Leiter holen, die Jellyfin für einen Titel
baut, und begründen, warum sie so aussieht
--spuren <host> <suche> Die Tonspuren eines Titels auflisten
""")
exit(1)
#endif