Wiedergabe: der Player spricht nicht mehr mit Plex, sondern mit dem Vertrag
Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar kannte. Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten, sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder angesprochen und konnte deshalb mit nichts anderem reden. `MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil es dafür keinen Fall gab. Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst (`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen muss. Drei Dinge, die beim Hinsehen auffielen: - **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig Minuten Fortschritt landet man bei zweiundvierzig. - **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus, weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem Öffnen fest. Jetzt tut es für die erste Meldung die des Servers. - **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf; bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört. Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht. Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag. Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst ein richtiger Server. 257 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
370f7f94f1
commit
fb418c842d
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
extension JellyfinBackend {
|
||||
// MARK: - Abspielen
|
||||
|
||||
public func preparePlayback(
|
||||
_ intent: PlaybackIntent
|
||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||
// Jellyfin trennt Fassung und Datei nicht: was die neutrale Form
|
||||
// „Datei" nennt, ist dort die Quelle, und ihre Kennung wird bei jedem
|
||||
// weiteren Schritt wieder verlangt.
|
||||
let sourceID = intent.file.key ?? intent.itemID
|
||||
|
||||
guard let transcode = intent.transcode else {
|
||||
// `static=true` schaltet jede Umrechnung ab: die Datei kommt so,
|
||||
// wie sie auf der Platte liegt. Ob das Gerät sie überhaupt
|
||||
// abspielen kann, ist vorher entschieden worden — und wenn nicht
|
||||
// der Systemplayer, dann VLC.
|
||||
guard let url = client.url(
|
||||
path: "/Videos/\(intent.itemID)/stream",
|
||||
query: [
|
||||
URLQueryItem(name: "static", value: "true"),
|
||||
URLQueryItem(name: "mediaSourceId", value: sourceID),
|
||||
],
|
||||
includingToken: account.token
|
||||
) else { throw .notFound }
|
||||
|
||||
// Auch hier eine Sitzungskennung, und zwar eine selbst vergebene:
|
||||
// ohne sie ordnet Jellyfin die Fortschrittsmeldungen keiner
|
||||
// Wiedergabe zu, und der Titel bliebe für den Server ungesehen.
|
||||
return PlaybackTarget(
|
||||
url: url,
|
||||
sessionID: UUID().uuidString,
|
||||
versionID: sourceID
|
||||
)
|
||||
}
|
||||
|
||||
let info = try await playbackInfo(
|
||||
itemID: intent.itemID,
|
||||
sourceID: sourceID,
|
||||
transcode: transcode
|
||||
)
|
||||
|
||||
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
|
||||
|
||||
// Die Adresse kommt vom Server, nicht von uns. Er weiß, welche Spuren
|
||||
// er kopieren kann und welche er neu rechnen muss; eine selbst
|
||||
// zusammengebaute Adresse wäre eine Vermutung darüber.
|
||||
guard let path = source?.transcodingURL,
|
||||
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL
|
||||
else {
|
||||
throw .refused(reason: String(
|
||||
localized: "player.transcodeUnavailable",
|
||||
bundle: .module
|
||||
))
|
||||
}
|
||||
|
||||
return PlaybackTarget(
|
||||
url: Self.limiting(height: transcode.resolution, in: url),
|
||||
sessionID: info.playSessionID,
|
||||
versionID: source?.id ?? sourceID
|
||||
)
|
||||
}
|
||||
|
||||
public func endPlayback(sessionID: String) async {
|
||||
// Ohne dieses Signal rechnet der Transcoder weiter, bis der Server von
|
||||
// selbst merkt, dass niemand mehr Segmente abholt. Bei direkter
|
||||
// Wiedergabe gibt es nichts anzuhalten — der Aufruf läuft dann ins
|
||||
// Leere, und das ist billiger als sich zu merken, welcher Fall es war.
|
||||
_ = try? await client.send(JellyfinRequest(
|
||||
method: .delete,
|
||||
path: "/Videos/ActiveEncodings",
|
||||
query: [
|
||||
URLQueryItem(name: "deviceId", value: client.deviceID),
|
||||
URLQueryItem(name: "playSessionId", value: sessionID),
|
||||
],
|
||||
timeout: .seconds(5)
|
||||
))
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async {
|
||||
var fields: [String: Any] = [
|
||||
"ItemId": report.itemID,
|
||||
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
||||
"IsPaused": report.state == .paused,
|
||||
"CanSeek": true,
|
||||
]
|
||||
if let sessionID = report.sessionID { fields["PlaySessionId"] = sessionID }
|
||||
if let versionID = report.versionID { fields["MediaSourceId"] = versionID }
|
||||
|
||||
// Erster und letzter Bericht gehen an eigene Endpunkte. Ohne den
|
||||
// ersten erscheint der Titel nirgends als „läuft gerade"; ohne den
|
||||
// letzten bleibt er für immer dort stehen. Ein Fortschritt mit
|
||||
// derselben Zahl täte weder das eine noch das andere.
|
||||
let path = switch report.state {
|
||||
case .started: "/Sessions/Playing"
|
||||
case .stopped: "/Sessions/Playing/Stopped"
|
||||
default: "/Sessions/Playing/Progress"
|
||||
}
|
||||
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
|
||||
_ = try? await client.send(JellyfinRequest(
|
||||
method: .post,
|
||||
path: path,
|
||||
body: body,
|
||||
timeout: .seconds(10)
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Hilfen
|
||||
|
||||
private func playbackInfo(
|
||||
itemID: String,
|
||||
sourceID: String,
|
||||
transcode: TranscodeSettings
|
||||
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
|
||||
// Jellyfin rechnet in Bit, die App in Kilobit.
|
||||
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
|
||||
|
||||
let profile: [String: Any] = [
|
||||
"MaxStreamingBitrate": bitsPerSecond,
|
||||
// Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits
|
||||
// entschieden, dass umgerechnet werden soll — dem Server die Wahl
|
||||
// zu lassen hieße, die Entscheidung zweimal zu treffen.
|
||||
"DirectPlayProfiles": [],
|
||||
"TranscodingProfiles": [[
|
||||
"Type": "Video",
|
||||
"Container": "ts",
|
||||
"Protocol": "hls",
|
||||
"VideoCodec": "h264",
|
||||
"AudioCodec": "aac,mp3",
|
||||
"Context": "Streaming",
|
||||
"MaxAudioChannels": "2",
|
||||
"MinSegments": 1,
|
||||
"BreakOnNonKeyFrames": true,
|
||||
]],
|
||||
"SubtitleProfiles": [
|
||||
["Format": "vtt", "Method": "Hls"],
|
||||
["Format": "srt", "Method": "External"],
|
||||
],
|
||||
]
|
||||
|
||||
let body: Data
|
||||
do {
|
||||
body = try JSONSerialization.data(withJSONObject: ["DeviceProfile": profile])
|
||||
} catch {
|
||||
throw .decoding(description: String(describing: error))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
JellyfinRequest(
|
||||
method: .post,
|
||||
path: "/Items/\(itemID)/PlaybackInfo",
|
||||
query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "mediaSourceId", value: sourceID),
|
||||
URLQueryItem(name: "maxStreamingBitrate", value: String(bitsPerSecond)),
|
||||
// **Kein** `startTimeTicks`. Es gäbe zwei Stellen, die
|
||||
// über den Startpunkt entscheiden: der Server ließe den
|
||||
// Strom dort beginnen, und der Player spränge anschließend
|
||||
// noch einmal um dieselbe Strecke vor — bei einundzwanzig
|
||||
// Minuten Fortschritt landete man bei zweiundvierzig.
|
||||
// Gesprungen wird an genau einer Stelle, und das ist der
|
||||
// Player.
|
||||
URLQueryItem(name: "enableDirectPlay", value: "false"),
|
||||
URLQueryItem(name: "enableDirectStream", value: "false"),
|
||||
URLQueryItem(name: "enableTranscoding", value: "true"),
|
||||
],
|
||||
body: body
|
||||
),
|
||||
as: JellyfinPlaybackInfo.self
|
||||
)
|
||||
}
|
||||
|
||||
/// Hängt die gewünschte Bildhöhe an die Adresse des Servers.
|
||||
///
|
||||
/// Die Bitrate versteht Jellyfin über das Geräteprofil, die Bildhöhe nicht
|
||||
/// ohne Weiteres. Sie wird deshalb nachträglich gesetzt — und eine bereits
|
||||
/// vorhandene Angabe ersetzt, statt eine zweite danebenzustellen: bei zwei
|
||||
/// gleichnamigen Werten gilt beim Server der erste, und das wäre dann
|
||||
/// gerade nicht der gewählte.
|
||||
///
|
||||
/// `resolution` kommt in der Form „1280x720"; gebraucht wird die zweite
|
||||
/// Zahl.
|
||||
static func limiting(height resolution: String?, in url: URL) -> URL {
|
||||
guard let resolution,
|
||||
let height = resolution.split(separator: "x").last.flatMap({ Int($0) }),
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return url }
|
||||
|
||||
var items = (components.queryItems ?? []).filter {
|
||||
let name = $0.name.lowercased()
|
||||
return name != "maxheight" && name != "maxwidth"
|
||||
}
|
||||
items.append(URLQueryItem(name: "maxHeight", value: String(height)))
|
||||
components.queryItems = items
|
||||
return components.url ?? url
|
||||
}
|
||||
}
|
||||
|
||||
/// Was der Server für eine Wiedergabe vorbereitet hat.
|
||||
public struct JellyfinPlaybackInfo: Decodable, Sendable {
|
||||
public let mediaSources: [JellyfinMediaSource]?
|
||||
public let playSessionID: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case mediaSources = "MediaSources"
|
||||
case playSessionID = "PlaySessionId"
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import MediaKit
|
||||
|
||||
/// Jellyfin als `MediaBackend`.
|
||||
public struct JellyfinBackend: MediaBackend {
|
||||
private let client: JellyfinClient
|
||||
private let account: JellyfinAccount
|
||||
let client: JellyfinClient
|
||||
let account: JellyfinAccount
|
||||
private let images = JellyfinImagePaths()
|
||||
public let serverName: String
|
||||
|
||||
@@ -251,12 +251,15 @@ public struct JellyfinBackend: MediaBackend {
|
||||
// MARK: - Herunterladen
|
||||
|
||||
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
|
||||
guard let sourceID = file.key else { return nil }
|
||||
// Im Pfad steht der Titel, in der Abfrage die Fassung. Beides zu
|
||||
// vertauschen fällt bei einem Film mit nur einer Fassung nicht auf —
|
||||
// die Kennungen sind dann dieselbe — und bei zweien sofort.
|
||||
guard let itemID = file.itemID ?? file.key else { return nil }
|
||||
return client.url(
|
||||
path: "/Videos/\(sourceID)/stream",
|
||||
path: "/Videos/\(itemID)/stream",
|
||||
query: [
|
||||
URLQueryItem(name: "static", value: "true"),
|
||||
URLQueryItem(name: "mediaSourceId", value: sourceID),
|
||||
URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID),
|
||||
],
|
||||
includingToken: account.token
|
||||
)
|
||||
|
||||
@@ -60,6 +60,12 @@ public actor JellyfinClient {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
/// Unter welcher Kennung der Server dieses Gerät führt.
|
||||
///
|
||||
/// Wird zum Abräumen gebraucht: eine laufende Umrechnung wird über Gerät
|
||||
/// **und** Sitzung angehalten, nicht über die Sitzung allein.
|
||||
public nonisolated var deviceID: String { identity.deviceID }
|
||||
|
||||
public nonisolated func authorization(token: String?) -> String {
|
||||
identity.authorizationHeader(token: token)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ extension JellyfinMediaSource {
|
||||
// trennt — hier ist die zweite eben einelementig.
|
||||
MediaFile(
|
||||
id: id ?? itemID,
|
||||
key: id,
|
||||
key: id ?? itemID,
|
||||
itemID: itemID,
|
||||
container: container,
|
||||
size: size,
|
||||
duration: runTimeTicks?.millisecondsFromTicks,
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
"sourceLanguage": "en",
|
||||
"version": "1.0",
|
||||
"strings": {
|
||||
"player.transcodeUnavailable": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat keine umgerechnete Fassung angeboten."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server didn't offer a transcoded version."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.continueWatching": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -50,6 +66,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.episodes": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Folgen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Episodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.movies": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -81,22 +113,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.episodes": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Folgen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Episodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,28 @@ public protocol MediaBackend: Sendable {
|
||||
resolution: String?
|
||||
) -> URL?
|
||||
|
||||
// MARK: - Abspielen
|
||||
|
||||
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
|
||||
///
|
||||
/// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing
|
||||
/// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum
|
||||
/// er nicht mitspielt, und die Antwort wurde weggeworfen.
|
||||
func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget
|
||||
|
||||
/// Beendet, was der Server für diese Wiedergabe angelegt hat.
|
||||
///
|
||||
/// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl
|
||||
/// niemand mehr zusieht.
|
||||
func endPlayback(sessionID: String) async
|
||||
|
||||
/// Meldet, wo man gerade steht.
|
||||
///
|
||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||
/// wäre die schlechtere Hälfte des Tauschs.
|
||||
func reportProgress(_ report: PlaybackReport) async
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
func markWatched(id: String) async throws(MediaBackendError)
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// Was beim Sprechen mit einem Server schiefgehen kann.
|
||||
///
|
||||
/// Dieselben sechs Fälle galten schon für Plex — sie sind nicht serverbezogen,
|
||||
/// Sechs der sieben Fälle galten schon für Plex — sie sind nicht serverbezogen,
|
||||
/// sondern beschreiben die Lage, in der man steckt. Dass es sie jetzt nur
|
||||
/// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in
|
||||
/// lesbaren Text stand an zwei Stellen, und die beiden waren bereits
|
||||
@@ -19,4 +19,11 @@ public enum MediaBackendError: Error, Equatable, Sendable {
|
||||
case transport(description: String)
|
||||
/// Der Server hat geantwortet, aber anders als erwartet.
|
||||
case decoding(description: String)
|
||||
/// Der Server hat verstanden und abgelehnt — mit eigener Begründung.
|
||||
///
|
||||
/// Nicht `.server(status:)`: dort steht eine Zahl, hier ein Satz, den der
|
||||
/// Server selbst formuliert hat. Beim Live-TV war genau dieser Satz die
|
||||
/// einzige brauchbare Spur — und er ging verloren, solange es diesen Fall
|
||||
/// nicht gab.
|
||||
case refused(reason: String)
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ public struct MediaPerson: Sendable, Equatable, Identifiable {
|
||||
}
|
||||
|
||||
/// Ein Abschnitt, den man überspringen möchte.
|
||||
public struct MediaMarker: Sendable, Equatable {
|
||||
public struct MediaMarker: Sendable, Equatable, Identifiable {
|
||||
public enum Kind: String, Sendable {
|
||||
case intro, credits, commercial, unknown
|
||||
}
|
||||
@@ -236,6 +236,17 @@ public struct MediaMarker: Sendable, Equatable {
|
||||
public func contains(_ milliseconds: Int) -> Bool {
|
||||
milliseconds >= startMilliseconds && milliseconds < endMilliseconds
|
||||
}
|
||||
|
||||
/// Der Anfang taugt als Kennung.
|
||||
///
|
||||
/// Zwei Marken derselben Sendung können nicht an derselben Stelle
|
||||
/// beginnen, und der Player braucht die Kennung nur, um sich zu merken,
|
||||
/// was er schon übersprungen hat — ohne dieses Gedächtnis spränge ein
|
||||
/// Rücksprung ins Intro es sofort wieder weg.
|
||||
public var id: Int { startMilliseconds }
|
||||
|
||||
public var isIntro: Bool { kind == .intro }
|
||||
public var isCredits: Bool { kind == .credits }
|
||||
}
|
||||
|
||||
/// Ein zusätzliches Bild zu einem Titel.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
|
||||
public init(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
file: MediaFile,
|
||||
transcode: TranscodeSettings? = nil,
|
||||
startTime: Duration = .zero,
|
||||
isLocalConnection: Bool = false
|
||||
) {
|
||||
self.itemID = itemID
|
||||
self.versionIndex = versionIndex
|
||||
self.file = file
|
||||
self.transcode = transcode
|
||||
self.startTime = startTime
|
||||
self.isLocalConnection = isLocalConnection
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?
|
||||
|
||||
public init(maximumBitrate: Int?, resolution: String?) {
|
||||
self.maximumBitrate = maximumBitrate
|
||||
self.resolution = resolution
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {}
|
||||
@@ -107,6 +107,14 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
/// Der Pfad, über den die Datei abgerufen wird.
|
||||
public let key: String?
|
||||
/// Zu welchem Titel diese Datei gehört.
|
||||
///
|
||||
/// Plex adressiert Dateien unmittelbar und braucht das nicht; Jellyfin
|
||||
/// adressiert über den Titel und nennt die Datei nur als Beiwerk. Ohne
|
||||
/// diese Angabe traf die Adresse dort den falschen Weg, sobald ein Titel
|
||||
/// in mehreren Fassungen vorliegt — und nur dann, was den Fehler zu einem
|
||||
/// gemacht hätte, den man erst spät findet.
|
||||
public let itemID: String?
|
||||
public let container: String?
|
||||
/// Größe in Bytes.
|
||||
///
|
||||
@@ -121,6 +129,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
|
||||
public init(
|
||||
id: String,
|
||||
key: String? = nil,
|
||||
itemID: String? = nil,
|
||||
container: String? = nil,
|
||||
size: Int64? = nil,
|
||||
duration: Int? = nil,
|
||||
@@ -129,6 +138,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
|
||||
) {
|
||||
self.id = id
|
||||
self.key = key
|
||||
self.itemID = itemID
|
||||
self.container = container
|
||||
self.size = size
|
||||
self.duration = duration
|
||||
|
||||
@@ -181,7 +181,11 @@ extension PlexServer {
|
||||
query: [
|
||||
URLQueryItem(name: "ratingKey", value: ratingKey),
|
||||
URLQueryItem(name: "key", value: "/library/metadata/\(ratingKey)"),
|
||||
URLQueryItem(name: "state", value: state.rawValue),
|
||||
// Plex kennt keinen eigenen Zustand für den Beginn.
|
||||
URLQueryItem(
|
||||
name: "state",
|
||||
value: state == .started ? "playing" : state.rawValue
|
||||
),
|
||||
URLQueryItem(name: "time", value: String(time.milliseconds)),
|
||||
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
|
||||
],
|
||||
@@ -227,15 +231,6 @@ extension PlexServer {
|
||||
}
|
||||
|
||||
/// In welchem Zustand sich die Wiedergabe beim Melden befindet.
|
||||
public enum PlaybackReportState: String, Sendable {
|
||||
case playing
|
||||
case paused
|
||||
/// Beim Verlassen des Players. Erst dieser Wert lässt Plex den Eintrag aus
|
||||
/// „wird gerade abgespielt" entfernen.
|
||||
case stopped
|
||||
case buffering
|
||||
}
|
||||
|
||||
extension PlexServer {
|
||||
/// Die nächste Folge nach der angegebenen.
|
||||
///
|
||||
|
||||
@@ -122,6 +122,53 @@ public struct PlexBackend: MediaBackend {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Abspielen
|
||||
|
||||
public func preparePlayback(
|
||||
_ intent: PlaybackIntent
|
||||
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||
guard let transcode = intent.transcode else {
|
||||
// Unverändert übertragen: die Datei selbst, ohne Sitzung — es gibt
|
||||
// nichts anzuhalten, was der Server angelegt hätte.
|
||||
guard let key = intent.file.key,
|
||||
let url = server.directPlayURL(part: PlexPart(id: 0, key: key))
|
||||
else { throw .notFound }
|
||||
return PlaybackTarget(url: url)
|
||||
}
|
||||
|
||||
let request = TranscodeRequest(
|
||||
ratingKey: intent.itemID,
|
||||
mediaIndex: intent.versionIndex,
|
||||
maximumBitrate: transcode.maximumBitrate,
|
||||
resolution: transcode.resolution,
|
||||
isLocal: intent.isLocalConnection
|
||||
)
|
||||
|
||||
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier schon
|
||||
// nein, steht der Grund im Klartext da — statt eines schwarzen Bildes
|
||||
// nach zwanzig Sekunden Warten.
|
||||
let decision = try await mapping { try await server.transcodeDecision(for: request) }
|
||||
guard decision.isAllowed else {
|
||||
throw .refused(reason: decision.explanation ?? "")
|
||||
}
|
||||
|
||||
guard let url = server.transcodeURL(for: request) else { throw .notFound }
|
||||
return PlaybackTarget(url: url, sessionID: request.sessionIdentifier)
|
||||
}
|
||||
|
||||
public func endPlayback(sessionID: String) async {
|
||||
await server.stopTranscode(sessionIdentifier: sessionID)
|
||||
}
|
||||
|
||||
public func reportProgress(_ report: PlaybackReport) async {
|
||||
await server.reportProgress(
|
||||
ratingKey: report.itemID,
|
||||
state: report.state,
|
||||
time: report.time,
|
||||
duration: report.duration
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Zustand melden
|
||||
|
||||
public func markWatched(id: String) async throws(MediaBackendError) {
|
||||
|
||||
Reference in New Issue
Block a user