Ein Blu-ray-Rip liegt als Matroska vor, und AVFoundation oeffnet Matroska nicht — obwohl das Bild darin H.264 oder HEVC ist und der Ton AC3, also alles, was AVPlayer bestens kann. Im Weg stand allein die Verpackung. Dafuer gibt es jetzt einen dritten Weg neben Durchreichen und Neurechnen: umpacken. Dieselben Stroeme, Byte fuer Byte, in eine MP4. Kein Bild wird angefasst, es geht nichts an Qualitaet verloren, und den Server kostet es fast nichts. Entscheidend ist dabei `Protocol: http` statt `hls`. Eine Abspielliste laesst sich nicht vorausladen — AVPlayer holt Segment fuer Segment, und Apple laesst uns dazwischen nicht eingreifen. Eine fortlaufende Datei kommt ueber gewoehnliche Bereichsanfragen, und die kann man so weit vorausholen, wie man will. Der Vorratswunsch steht dort deshalb bei zehn Minuten statt bei einer. Der Gewinn ist gross: solche Titel bekommen Bild-in-Bild, AirPlay und die Systembedienung, die es bei VLC nicht gibt. Umgepackt wird nur, wenn **allein** die Schachtel im Weg steht. Stimmt auch nur ein Codec nicht — DTS, TrueHD, AV1 —, fehlt nicht der Zugang zur Datei, sondern das Koennen; dann bleibt es bei VLC. Sechs Tests halten die Grenze fest, in beide Richtungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
6.2 KiB
Swift
166 lines
6.2 KiB
Swift
import Foundation
|
|
|
|
/// Was abgespielt werden soll und wie.
|
|
///
|
|
/// Die Entscheidung *ob* umgerechnet wird, fällt vorher — im
|
|
/// `PlaybackDecider`, aus Profil und Leitung. Hier steht nur noch, was der
|
|
/// Server daraufhin bereitstellen soll. Das ist die Trennung, die es vorher
|
|
/// nicht gab: der Player hat Plex' Transcoder unmittelbar angesprochen und
|
|
/// konnte deshalb mit keinem anderen Server reden.
|
|
public struct PlaybackIntent: Sendable, Equatable {
|
|
public let itemID: String
|
|
/// Welche Fassung — ein Film kann mehrfach vorliegen.
|
|
public let versionIndex: Int
|
|
/// Die Datei innerhalb der Fassung. Plex adressiert Dateien, Jellyfin
|
|
/// Quellen; beide finden sich hierüber wieder.
|
|
public let file: MediaFile
|
|
/// `nil` heißt: unverändert übertragen.
|
|
public let transcode: TranscodeSettings?
|
|
public let startTime: Duration
|
|
/// Ob der Server im selben Netz steht. Plex rechnet dann anders.
|
|
public let isLocalConnection: Bool
|
|
/// Welche Tonspur gespielt werden soll.
|
|
///
|
|
/// **Ohne diese Angabe wählt der Server.** Bei einem umgerechneten Stream
|
|
/// gibt es danach nur noch *eine* Tonspur — was hier nicht gesagt wird,
|
|
/// lässt sich später nicht mehr umstellen, weil im Player schlicht keine
|
|
/// zweite ankommt. Genau daran scheiterte die Sprachwahl bisher: sie lief
|
|
/// auf einer Liste mit einem Eintrag.
|
|
public let audioStreamIndex: Int?
|
|
/// Welcher Untertitel eingebrannt oder mitgeliefert werden soll.
|
|
///
|
|
/// Dieselbe Lage wie beim Ton: rechnet der Server um, liefert er genau
|
|
/// den einen aus, für den er sich entschieden hat. Wer erst im Player
|
|
/// wählen will, hat dann nichts mehr zur Auswahl.
|
|
///
|
|
/// **`-1` heisst ausdrücklich „keine".** Nicht dasselbe wie `nil`: das
|
|
/// hiesse „such dir was aus", und der Server sucht sich dann durchaus
|
|
/// etwas aus.
|
|
public let subtitleStreamIndex: Int?
|
|
|
|
public init(
|
|
itemID: String,
|
|
versionIndex: Int,
|
|
file: MediaFile,
|
|
transcode: TranscodeSettings? = nil,
|
|
startTime: Duration = .zero,
|
|
isLocalConnection: Bool = false,
|
|
audioStreamIndex: Int? = nil,
|
|
subtitleStreamIndex: Int? = nil
|
|
) {
|
|
self.itemID = itemID
|
|
self.versionIndex = versionIndex
|
|
self.file = file
|
|
self.transcode = transcode
|
|
self.startTime = startTime
|
|
self.isLocalConnection = isLocalConnection
|
|
self.audioStreamIndex = audioStreamIndex
|
|
self.subtitleStreamIndex = subtitleStreamIndex
|
|
}
|
|
}
|
|
|
|
/// Wie weit heruntergerechnet werden soll.
|
|
public struct TranscodeSettings: Sendable, Equatable {
|
|
/// In Kilobit je Sekunde. `nil` heißt „so gut wie möglich".
|
|
public let maximumBitrate: Int?
|
|
/// Etwa „1280x720". `nil` lässt die Auflösung, wie sie ist.
|
|
public let resolution: String?
|
|
/// Ob der Server eine **Leiter** liefern soll statt einer festen Stufe.
|
|
///
|
|
/// Dann enthält die Playlist mehrere Varianten, und der Player wechselt
|
|
/// zwischen ihnen, ohne dass der Stream neu aufgebaut werden muss. Die
|
|
/// angegebene Bitrate bleibt dabei die **oberste** Sprosse; was darunter
|
|
/// liegt, erfindet der Server.
|
|
///
|
|
/// Nur bei `automatic` gesetzt. Wer im Sheet ausdrücklich „720p · 3 Mbps"
|
|
/// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger"
|
|
/// unter unverändertem Etikett.
|
|
public let prefersAdaptive: Bool
|
|
/// Nur umpacken, nicht neu rechnen.
|
|
///
|
|
/// Dann werden die Ströme kopiert statt kodiert, und heraus kommt eine
|
|
/// **fortlaufende** MP4 statt einer HLS-Abspielliste. Beides ist wichtig:
|
|
/// kopieren verliert nichts und kostet den Server fast nichts, und eine
|
|
/// fortlaufende Datei lässt sich beliebig weit vorausladen — eine
|
|
/// Abspielliste nicht.
|
|
public let prefersRemux: Bool
|
|
|
|
public init(
|
|
maximumBitrate: Int?,
|
|
resolution: String?,
|
|
prefersAdaptive: Bool = false,
|
|
prefersRemux: Bool = false
|
|
) {
|
|
self.maximumBitrate = maximumBitrate
|
|
self.resolution = resolution
|
|
self.prefersAdaptive = prefersAdaptive
|
|
self.prefersRemux = prefersRemux
|
|
}
|
|
}
|
|
|
|
/// Was der Server für diese Wiedergabe bereitgestellt hat.
|
|
public struct PlaybackTarget: Sendable, Equatable {
|
|
public let url: URL
|
|
/// Die Sitzung, die der Server dafür angelegt hat.
|
|
///
|
|
/// Zum Aufräumen: ohne dieses Kennzeichen rechnet der Transcoder weiter,
|
|
/// wenn längst niemand mehr zusieht.
|
|
public let sessionID: String?
|
|
/// Welche Fassung gespielt wird.
|
|
///
|
|
/// Jellyfin will sie bei jeder Fortschrittsmeldung zurückhaben, sonst
|
|
/// ordnet es die Meldung keiner Wiedergabe zu.
|
|
public let versionID: String?
|
|
|
|
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
|
|
self.url = url
|
|
self.sessionID = sessionID
|
|
self.versionID = versionID
|
|
}
|
|
}
|
|
|
|
/// In welchem Zustand sich die Wiedergabe befindet.
|
|
public enum PlaybackReportState: String, Sendable {
|
|
/// Der erste Bericht einer Wiedergabe.
|
|
///
|
|
/// Eigener Fall, weil Jellyfin dafür einen eigenen Endpunkt hat: ohne ihn
|
|
/// erscheint der Titel nirgends als „läuft gerade", und die Sitzung, unter
|
|
/// der die weiteren Meldungen laufen, ist dem Server unbekannt. Plex
|
|
/// unterscheidet das nicht und behandelt ihn wie „spielt".
|
|
case started
|
|
case playing
|
|
case paused
|
|
/// Beim Verlassen des Players. Erst dieser Wert nimmt den Titel auf dem
|
|
/// Server aus „wird gerade abgespielt" heraus.
|
|
case stopped
|
|
case buffering
|
|
}
|
|
|
|
/// Wo man gerade steht.
|
|
public struct PlaybackReport: Sendable, Equatable {
|
|
public let itemID: String
|
|
public let sessionID: String?
|
|
public let versionID: String?
|
|
public let state: PlaybackReportState
|
|
public let time: Duration
|
|
public let duration: Duration
|
|
|
|
public init(
|
|
itemID: String,
|
|
sessionID: String? = nil,
|
|
versionID: String? = nil,
|
|
state: PlaybackReportState,
|
|
time: Duration,
|
|
duration: Duration
|
|
) {
|
|
self.itemID = itemID
|
|
self.sessionID = sessionID
|
|
self.versionID = versionID
|
|
self.state = state
|
|
self.time = time
|
|
self.duration = duration
|
|
}
|
|
}
|
|
|
|
extension PlaybackReportState: Equatable {}
|