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: ". ")) } }