Files
Flaro/Packages/FlaroKit/Sources/FlaroPlayback/Decision/PlaybackDecider.swift
T
ScarriffleandClaude Opus 5 a7cf6c5def 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>
2026-10-03 19:38:27 +02:00

147 lines
6.7 KiB
Swift

import Foundation
import MediaKit
/// Entscheidet, wie ein Titel abgespielt wird.
///
/// Reine Rechnung ohne Netz und ohne Zustand — deshalb vollständig testbar.
/// Die zugehörige Testtabelle in `PlaybackDeciderTests` ist die wichtigste im
/// Projekt: sie hält fest, welche Datei auf welchem Weg landet, und schlägt
/// an, sobald jemand die Fähigkeitenliste anfasst.
public struct PlaybackDecider: Sendable {
private let capabilities: DeviceCapabilities
public init(capabilities: DeviceCapabilities = .avFoundation) {
self.capabilities = capabilities
}
public func decide(
profile: MediaProfile,
policy: PlaybackPolicy
) -> PlaybackDecision {
var rationale: [PlaybackDecision.Rationale] = []
// 0. Hat jemand ausdrücklich eine Stufe gewählt, wird umgerechnet —
// auch wenn die Datei die Grenze gar nicht überschreitet. Sonst
// bliebe die Wahl folgenlos: für eine 8-Mbit-Datei „12 Mbit" zu
// wählen ergäbe DirectPlay, und am Bild änderte sich nichts.
if policy.forcesTranscoding, policy.allowsTranscoding {
rationale.append(.qualityChosen)
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
// 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
if let limit = policy.maximumBitrate, let bitrate = profile.bitrate, bitrate > limit {
rationale.append(.bitrateExceedsLimit(mediaBitrate: bitrate, limit: limit))
if policy.allowsTranscoding {
return PlaybackDecision(method: .transcode(maximumBitrate: limit), rationale: rationale)
}
// Ohne Transcoder bleibt nur, es trotzdem zu versuchen. Das ist
// ehrlicher als eine Fehlermeldung: im WLAN klappt es meistens,
// und der Grund steht in der Begründung.
rationale.append(.transcodingUnavailable)
}
// 2. Kennt der Server die Datei überhaupt?
let container = normalized(profile.container)
guard profile.hasKnownStreams, let videoCodec = normalized(profile.videoCodec) else {
rationale.append(.streamsUnknown)
return fallback(policy: policy, rationale: rationale)
}
// 3. Kann AVPlayer das?
var blockers: [PlaybackDecision.Rationale] = []
if let container, !capabilities.containers.contains(container) {
blockers.append(.containerUnsupported(container))
}
if !capabilities.videoCodecs.contains(videoCodec) {
blockers.append(.videoCodecUnsupported(videoCodec))
}
// Geprüft wird die Spur, die tatsächlich klingen soll — welche das
// ist, hat der Aufrufer beim Bauen des Profils entschieden.
if let audioCodec = normalized(profile.audioCodec),
!capabilities.audioCodecs.contains(audioCodec) {
blockers.append(.audioCodecUnsupported(audioCodec))
}
guard blockers.isEmpty else {
rationale.append(contentsOf: blockers)
// **Solange das Bild durchgereicht werden kann, hilft der Server.**
//
// Zwei Fälle, und beide enden hier. Steht nur die Schachtel im
// Weg — Matroska aussen, H.264 oder HEVC innen, AC3 dazu —, wird
// umgepackt: dieselben Ströme, andere Verpackung, nichts geht
// verloren. Steht der **Ton** im Weg — DTS, TrueHD, Vorbis —,
// rechnet der Server genau diese eine Spur um und kopiert das
// Bild. Beides zusammen geht ebenso.
//
// Das kostet Tonqualität, und zwar nur beim Ton. Dagegen steht,
// was es einbringt: der Titel läuft überhaupt — mit Bild-in-Bild,
// AirPlay und Sperrbildschirm, und ohne dass das ganze Bild neu
// gerechnet werden muss.
//
// Stimmt dagegen der **Videocodec** nicht, ist hier Schluss: dann
// fehlt nicht der Zugang, sondern das Können, und jeder Weg über
// den Server hiesse, jedes Bild neu zu rechnen.
let bildBleibt = blockers.allSatisfy {
switch $0 {
case .containerUnsupported, .audioCodecUnsupported: true
default: false
}
}
// Umpackbar muss die Schachtel nur sein, wenn sie überhaupt im
// Weg steht. Eine MP4 mit DTS-Ton bleibt die MP4, die sie ist —
// sie an dieser Liste zu messen, hiesse sie an einer Frage
// scheitern zu lassen, die sich gar nicht stellt.
let schachtelTraegt = container.map {
capabilities.containers.contains($0)
|| DeviceCapabilities.remuxableContainers.contains($0)
} ?? true
if bildBleibt, schachtelTraegt, policy.allowsTranscoding {
return PlaybackDecision(method: .remux, rationale: rationale)
}
return fallback(policy: policy, rationale: rationale)
}
rationale.append(.nativelySupported)
return PlaybackDecision(method: .directPlay, rationale: rationale)
}
/// Was passiert, wenn AVPlayer nicht kann.
///
/// **Hier stand einmal eine zweite Engine.** VLCKit hatte Vorrang vor dem
/// Transcoder: die Datei ging unverändert über die Leitung, der Server
/// musste nicht rechnen. Es gibt sie nicht mehr — was der Systemplayer
/// nicht öffnet, rechnet der Server um, und was heruntergeladen wird,
/// kommt schon in einer Hülle, die er öffnet.
private func fallback(
policy: PlaybackPolicy,
rationale: [PlaybackDecision.Rationale]
) -> PlaybackDecision {
if policy.allowsTranscoding {
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
var rationale = rationale
rationale.append(.transcodingUnavailable)
return PlaybackDecision(method: .directPlay, rationale: rationale)
}
/// Plex schreibt Codecs mal groß, mal klein, und hängt bei Containern
/// gelegentlich Punkte an. Ein Vergleich ohne diese Angleichung geht
/// sporadisch schief — und zwar nur bei manchen Dateien, was die Suche
/// nach dem Grund unangenehm macht.
private func normalized(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ". "))
}
}