Erster Schritt zu Jellyfin, und er besteht vor allem aus Wegnehmen. `PlaybackDecider` nahm bisher Plex-Modelle entgegen und las daraus sechs Felder. Das band die Entscheidung an einen bestimmten Server, obwohl an der Frage „kann iOS das öffnen?" nichts serverspezifisch ist. Jetzt nimmt er ein `MediaProfile` — Container, Codecs, Bitrate, ob die Datei untersucht ist. Wie ernst die Kopplung war, zeigte der Player selbst: für die Offline-Wiedergabe baute er sich **erfundene** Plex-Werte zusammen — eine Media mit Kennung 0, ein Part mit dem Dateinamen als Schlüssel, zusammengesetzte Spurlisten —, nur damit die Signatur passte. Für eine Datei, die längst auf dem Gerät lag und mit Plex nichts mehr zu tun hatte. Dieser Hack ist weg. Neu ist das Target `MediaKit`: Typen und Rechnungen, die für jeden Server gelten, ohne eine einzige Abhängigkeit. Darin `MediaProfile` und die `Duration`-Brücke, die bisher in PlexKit stand — „weil es die unterste Schicht ist". Das stimmte, solange es nur einen Server gab. Damit kennen Wiedergabe und Downloads Plex nicht mehr. Zwei der Abhängigkeiten waren ohnehin tot; die dritte trug nur diese Brücke. Die wichtigste Testtabelle des Projekts ist mitgewandert und dabei kürzer geworden: dort steht jetzt, worauf es ankommt, statt vollständiger Modelle mit Kennungen und Dateipfaden, von denen niemand etwas las. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.9 KiB
Swift
112 lines
4.9 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)
|
|
return fallback(policy: policy, rationale: rationale)
|
|
}
|
|
|
|
rationale.append(.nativelySupported)
|
|
return PlaybackDecision(method: .directPlay(engine: .avPlayer), rationale: rationale)
|
|
}
|
|
|
|
/// Was passiert, wenn AVPlayer nicht kann.
|
|
///
|
|
/// VLCKit hat Vorrang vor dem Transcoder: die Datei geht unverändert über
|
|
/// die Leitung, der Server muss nicht rechnen, und die Bildqualität bleibt
|
|
/// die des Originals. Umgerechnet wird erst, wenn auch VLC ausscheidet.
|
|
private func fallback(
|
|
policy: PlaybackPolicy,
|
|
rationale: [PlaybackDecision.Rationale]
|
|
) -> PlaybackDecision {
|
|
if policy.allowsFallbackEngine {
|
|
return PlaybackDecision(method: .directPlay(engine: .vlc), rationale: rationale)
|
|
}
|
|
if policy.allowsTranscoding {
|
|
return PlaybackDecision(
|
|
method: .transcode(maximumBitrate: policy.maximumBitrate),
|
|
rationale: rationale
|
|
)
|
|
}
|
|
var rationale = rationale
|
|
rationale.append(.transcodingUnavailable)
|
|
return PlaybackDecision(method: .directPlay(engine: .avPlayer), 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: ". "))
|
|
}
|
|
}
|