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
+3146
-3148
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,26 @@ final class FlixySession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wie die Verbindung zum Server zu beschreiben ist.
|
||||||
|
///
|
||||||
|
/// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß
|
||||||
|
/// die Sitzung. Bei Plex ist es die entscheidende Angabe — über die
|
||||||
|
/// Vermittlung von plex.tv ist alles gedrosselt, und das erklärt die
|
||||||
|
/// Hälfte aller Ruckler, bevor man weitersucht.
|
||||||
|
var connectionDescription: String? {
|
||||||
|
switch kind {
|
||||||
|
case .plex:
|
||||||
|
guard let connection = server?.connection else { return nil }
|
||||||
|
if connection.local { return "Lokal · \(connection.uri)" }
|
||||||
|
if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
|
||||||
|
return "Direkt von außen · \(connection.uri)"
|
||||||
|
case .jellyfin:
|
||||||
|
return serverAddress
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ob der aktive Server Live-TV anbietet, **so wie die App es heute kann**.
|
/// Ob der aktive Server Live-TV anbietet, **so wie die App es heute kann**.
|
||||||
///
|
///
|
||||||
/// Nicht `capabilities.contains(.liveTV)`: Jellyfin kann es, der
|
/// Nicht `capabilities.contains(.liveTV)`: Jellyfin kann es, der
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import FlixyPlayback
|
import FlixyPlayback
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Hält fest, was beim letzten Abspielversuch passiert ist.
|
/// Hält fest, was beim letzten Abspielversuch passiert ist.
|
||||||
///
|
///
|
||||||
@@ -30,12 +29,12 @@ final class PlaybackDiagnostics {
|
|||||||
|
|
||||||
private(set) var lastAttempt: Attempt?
|
private(set) var lastAttempt: Attempt?
|
||||||
|
|
||||||
func begin(title: String, connection: PlexConnection?) {
|
func begin(title: String, connection: String?) {
|
||||||
lastAttempt = Attempt(
|
lastAttempt = Attempt(
|
||||||
startedAt: Date(),
|
startedAt: Date(),
|
||||||
title: title,
|
title: title,
|
||||||
method: "—",
|
method: "—",
|
||||||
connection: connection.map(Self.describe) ?? "—",
|
connection: connection ?? "—",
|
||||||
sourceDescription: "—"
|
sourceDescription: "—"
|
||||||
)
|
)
|
||||||
note("Player geöffnet")
|
note("Player geöffnet")
|
||||||
@@ -76,11 +75,6 @@ final class PlaybackDiagnostics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func describe(_ connection: PlexConnection) -> String {
|
|
||||||
if connection.local { return "Lokal · \(connection.uri)" }
|
|
||||||
if connection.relay { return "Plex-Vermittlung (gedrosselt)" }
|
|
||||||
return "Direkt von außen · \(connection.uri)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func describe(_ url: URL) -> String {
|
private static func describe(_ url: URL) -> String {
|
||||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||||
|
|||||||
@@ -114,8 +114,23 @@ struct DownloadButton: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Nur Stufen, die unter der Auflösung der Datei liegen.
|
/// Nur Stufen, die unter der Auflösung der Datei liegen.
|
||||||
|
///
|
||||||
|
/// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt.
|
||||||
|
/// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die
|
||||||
|
/// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage.
|
||||||
private var availableQualities: [DownloadQuality] {
|
private var availableQualities: [DownloadQuality] {
|
||||||
DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
|
let all = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
|
||||||
|
guard canDownloadCompressed else { return [.original] }
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canDownloadCompressed: Bool {
|
||||||
|
session.backend?.compressedDownloadURL(
|
||||||
|
itemID: item.id,
|
||||||
|
versionIndex: mediaIndex,
|
||||||
|
maximumBitrate: 8_000,
|
||||||
|
resolution: nil
|
||||||
|
) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Beschriftung samt Auflösung und geschätzter Größe.
|
/// Die Beschriftung samt Auflösung und geschätzter Größe.
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ enum ErrorText {
|
|||||||
case .server(let status): String(localized: "error.server \(status)")
|
case .server(let status): String(localized: "error.server \(status)")
|
||||||
case .transport(let description): description
|
case .transport(let description): description
|
||||||
case .decoding: String(localized: "error.unexpectedResponse")
|
case .decoding: String(localized: "error.unexpectedResponse")
|
||||||
|
// Die Worte des Servers, nicht unsere: er weiß besser, warum er
|
||||||
|
// ablehnt, als wir es raten könnten.
|
||||||
|
case .refused(let reason): reason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,12 +121,15 @@ struct RootView: View {
|
|||||||
|
|
||||||
let fresh = PlayerModel(
|
let fresh = PlayerModel(
|
||||||
request: request,
|
request: request,
|
||||||
server: session.server,
|
backend: session.backend,
|
||||||
|
// Live-TV spricht als Einziges noch unmittelbar mit Plex.
|
||||||
|
liveTV: session.server,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
diagnostics: diagnostics,
|
diagnostics: diagnostics,
|
||||||
policy: settings.policy(for: network),
|
policy: settings.policy(for: network),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
isLocalConnection: session.server?.connection.local ?? false,
|
isLocalConnection: session.server?.connection.local ?? false,
|
||||||
|
connectionDescription: session.connectionDescription,
|
||||||
unavailableMessage: session.playbackUnavailableMessage
|
unavailableMessage: session.playbackUnavailableMessage
|
||||||
)
|
)
|
||||||
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
|
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ final class PlayerModel {
|
|||||||
|
|
||||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||||
/// Überspringen-Knopf.
|
/// Überspringen-Knopf.
|
||||||
private(set) var activeMarker: PlexMarker?
|
private(set) var activeMarker: MediaMarker?
|
||||||
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||||
private(set) var nextEpisode: PlexItem?
|
private(set) var nextEpisode: MediaItem?
|
||||||
private(set) var hasReachedEnd = false
|
private(set) var hasReachedEnd = false
|
||||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
||||||
@@ -93,12 +93,25 @@ final class PlayerModel {
|
|||||||
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
|
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
|
||||||
|
|
||||||
private let request: PlaybackRequest
|
private let request: PlaybackRequest
|
||||||
private let server: PlexServer?
|
private let backend: (any MediaBackend)?
|
||||||
|
/// Der Plex-Server — nur noch für Live-TV.
|
||||||
|
///
|
||||||
|
/// Programmführer und Tuner sind das Einzige, was hier noch unmittelbar
|
||||||
|
/// mit Plex spricht. 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. Bei Jellyfin ist er `nil`.
|
||||||
|
private let liveTV: PlexServer?
|
||||||
private let downloads: DownloadCoordinator
|
private let downloads: DownloadCoordinator
|
||||||
private let diagnostics: PlaybackDiagnostics
|
private let diagnostics: PlaybackDiagnostics
|
||||||
private let policy: PlaybackPolicy
|
private let policy: PlaybackPolicy
|
||||||
private let settings: PlaybackSettings
|
private let settings: PlaybackSettings
|
||||||
private let isLocalConnection: Bool
|
private let isLocalConnection: Bool
|
||||||
|
/// Wie die Verbindung in der Diagnose heißt.
|
||||||
|
///
|
||||||
|
/// Von außen gereicht: welcher Weg zum Server führt, weiß die Sitzung —
|
||||||
|
/// und über die Vermittlung von plex.tv ist alles gedrosselt, was die
|
||||||
|
/// Hälfte aller Ruckler erklärt, bevor man weitersucht.
|
||||||
|
private let connectionDescription: String?
|
||||||
/// Was zu sagen ist, wenn es keinen Server gibt, mit dem sich abspielen
|
/// Was zu sagen ist, wenn es keinen Server gibt, mit dem sich abspielen
|
||||||
/// ließe.
|
/// ließe.
|
||||||
///
|
///
|
||||||
@@ -107,16 +120,23 @@ final class PlayerModel {
|
|||||||
/// Server angemeldet ist, weiß die Sitzung, nicht der Player.
|
/// Server angemeldet ist, weiß die Sitzung, nicht der Player.
|
||||||
private let unavailableMessage: String
|
private let unavailableMessage: String
|
||||||
|
|
||||||
private var item: PlexItem?
|
private var item: MediaItem?
|
||||||
/// Die gerade gespielte Fassung und Datei.
|
/// Die gerade gespielte Fassung und Datei.
|
||||||
///
|
///
|
||||||
/// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann,
|
/// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann,
|
||||||
/// ohne die Metadaten erneut zu holen.
|
/// ohne die Metadaten erneut zu holen.
|
||||||
private var currentMedia: PlexMedia?
|
private var currentMedia: MediaVersion?
|
||||||
private var currentPart: PlexPart?
|
private var currentPart: MediaFile?
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
private var reportTask: Task<Void, Never>?
|
private var reportTask: Task<Void, Never>?
|
||||||
private var transcodeSession: String?
|
/// Was der Server für diese Wiedergabe angelegt hat.
|
||||||
|
///
|
||||||
|
/// Zum Abräumen und zum Melden: Jellyfin ordnet den Fortschritt über
|
||||||
|
/// dieselbe Kennung zu, über die Plex den Transcoder anhält.
|
||||||
|
private var playbackSession: String?
|
||||||
|
/// Welche Fassung gerade läuft — Jellyfin will sie bei jeder Meldung
|
||||||
|
/// zurückhaben.
|
||||||
|
private var playbackVersionID: String?
|
||||||
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
|
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
|
||||||
/// mehr.
|
/// mehr.
|
||||||
private var programmeEndsAt: Date?
|
private var programmeEndsAt: Date?
|
||||||
@@ -129,21 +149,25 @@ final class PlayerModel {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
request: PlaybackRequest,
|
request: PlaybackRequest,
|
||||||
server: PlexServer?,
|
backend: (any MediaBackend)?,
|
||||||
|
liveTV: PlexServer?,
|
||||||
downloads: DownloadCoordinator,
|
downloads: DownloadCoordinator,
|
||||||
diagnostics: PlaybackDiagnostics,
|
diagnostics: PlaybackDiagnostics,
|
||||||
policy: PlaybackPolicy,
|
policy: PlaybackPolicy,
|
||||||
settings: PlaybackSettings,
|
settings: PlaybackSettings,
|
||||||
isLocalConnection: Bool,
|
isLocalConnection: Bool,
|
||||||
|
connectionDescription: String?,
|
||||||
unavailableMessage: String
|
unavailableMessage: String
|
||||||
) {
|
) {
|
||||||
self.request = request
|
self.request = request
|
||||||
self.server = server
|
self.backend = backend
|
||||||
|
self.liveTV = liveTV
|
||||||
self.downloads = downloads
|
self.downloads = downloads
|
||||||
self.diagnostics = diagnostics
|
self.diagnostics = diagnostics
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.isLocalConnection = isLocalConnection
|
self.isLocalConnection = isLocalConnection
|
||||||
|
self.connectionDescription = connectionDescription
|
||||||
self.unavailableMessage = unavailableMessage
|
self.unavailableMessage = unavailableMessage
|
||||||
self.title = request.title
|
self.title = request.title
|
||||||
}
|
}
|
||||||
@@ -151,7 +175,7 @@ final class PlayerModel {
|
|||||||
// MARK: - Start
|
// MARK: - Start
|
||||||
|
|
||||||
func start() async {
|
func start() async {
|
||||||
diagnostics.begin(title: request.title, connection: server?.connection)
|
diagnostics.begin(title: request.title, connection: connectionDescription)
|
||||||
|
|
||||||
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
|
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
|
||||||
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
|
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
|
||||||
@@ -166,15 +190,15 @@ final class PlayerModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let server else {
|
guard let backend else {
|
||||||
phase = .failed(message: unavailableMessage)
|
phase = .failed(message: unavailableMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
diagnostics.note("Metadaten anfragen")
|
diagnostics.note("Metadaten anfragen")
|
||||||
let item: PlexItem
|
let item: MediaItem
|
||||||
do {
|
do {
|
||||||
item = try await server.metadata(ratingKey: request.ratingKey)
|
item = try await backend.item(id: request.ratingKey)
|
||||||
diagnostics.note("Metadaten da")
|
diagnostics.note("Metadaten da")
|
||||||
} catch {
|
} catch {
|
||||||
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))")
|
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))")
|
||||||
@@ -183,10 +207,10 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.item = item
|
self.item = item
|
||||||
title = item.asMediaItem.rowTitle
|
title = item.rowTitle
|
||||||
subtitle = playerSubtitle(for: item)
|
subtitle = playerSubtitle(for: item)
|
||||||
|
|
||||||
guard let media = version(of: item), let part = media.parts.first else {
|
guard let media = version(of: item), let part = media.files.first else {
|
||||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,10 +218,10 @@ final class PlayerModel {
|
|||||||
currentMedia = media
|
currentMedia = media
|
||||||
currentPart = part
|
currentPart = part
|
||||||
|
|
||||||
let decision = PlaybackDecider().decide(profile: media.profile(part: part), policy: policy)
|
let decision = PlaybackDecider().decide(profile: media.profile(file: part), policy: policy)
|
||||||
self.decision = decision
|
self.decision = decision
|
||||||
|
|
||||||
guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else {
|
guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else {
|
||||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,7 +244,7 @@ final class PlayerModel {
|
|||||||
let isRetry = { if case .failed = phase { return true } else { return false } }()
|
let isRetry = { if case .failed = phase { return true } else { return false } }()
|
||||||
guard newQuality != quality || isRetry,
|
guard newQuality != quality || isRetry,
|
||||||
!isOffline,
|
!isOffline,
|
||||||
let server, let item, let media = currentMedia, let part = currentPart
|
let backend, let item, let media = currentMedia, let part = currentPart
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
isChangingQuality = true
|
isChangingQuality = true
|
||||||
@@ -232,9 +256,9 @@ final class PlayerModel {
|
|||||||
|
|
||||||
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
||||||
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
||||||
if let transcodeSession {
|
if let playbackSession {
|
||||||
await server.stopTranscode(sessionIdentifier: transcodeSession)
|
await backend.endPlayback(sessionID: playbackSession)
|
||||||
self.transcodeSession = nil
|
self.playbackSession = nil
|
||||||
}
|
}
|
||||||
eventTask?.cancel()
|
eventTask?.cancel()
|
||||||
engine?.teardown()
|
engine?.teardown()
|
||||||
@@ -243,7 +267,7 @@ final class PlayerModel {
|
|||||||
|
|
||||||
let newPolicy = newQuality.policy(basedOn: policy)
|
let newPolicy = newQuality.policy(basedOn: policy)
|
||||||
let newDecision = PlaybackDecider().decide(
|
let newDecision = PlaybackDecider().decide(
|
||||||
profile: media.profile(part: part),
|
profile: media.profile(file: part),
|
||||||
policy: newPolicy
|
policy: newPolicy
|
||||||
)
|
)
|
||||||
decision = newDecision
|
decision = newDecision
|
||||||
@@ -253,7 +277,7 @@ final class PlayerModel {
|
|||||||
item: item,
|
item: item,
|
||||||
media: media,
|
media: media,
|
||||||
part: part,
|
part: part,
|
||||||
server: server,
|
backend: backend,
|
||||||
startingAt: resumeAt
|
startingAt: resumeAt
|
||||||
) else {
|
) else {
|
||||||
// Nur dann eine allgemeine Meldung, wenn `source` keine eigene
|
// Nur dann eine allgemeine Meldung, wenn `source` keine eigene
|
||||||
@@ -298,7 +322,7 @@ final class PlayerModel {
|
|||||||
// Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der
|
// Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der
|
||||||
// Tuner nach dem Schließen belegt — und beim nächsten Sender hat der
|
// Tuner nach dem Schließen belegt — und beim nächsten Sender hat der
|
||||||
// Server keinen freien mehr.
|
// Server keinen freien mehr.
|
||||||
transcodeSession = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
playbackSession = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
.queryItems?.first { $0.name == "session" }?.value
|
.queryItems?.first { $0.name == "session" }?.value
|
||||||
|
|
||||||
// Live kommt immer umgerechnet: der Tuner liefert einen
|
// Live kommt immer umgerechnet: der Tuner liefert einen
|
||||||
@@ -360,43 +384,45 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Stream.
|
/// Lässt den Server die Wiedergabe vorbereiten.
|
||||||
|
///
|
||||||
|
/// Was hier steht, ist für jeden Server dasselbe: die Entscheidung ist
|
||||||
|
/// gefallen, der Server soll sie ausführen. Wie er das tut — Plex' Universal
|
||||||
|
/// Transcoder, Jellyfins PlaybackInfo — steht in der jeweiligen Hülle.
|
||||||
private func source(
|
private func source(
|
||||||
for decision: PlaybackDecision,
|
for decision: PlaybackDecision,
|
||||||
item: PlexItem,
|
item: MediaItem,
|
||||||
media: PlexMedia,
|
media: MediaVersion,
|
||||||
part: PlexPart,
|
part: MediaFile,
|
||||||
server: PlexServer,
|
backend: any MediaBackend,
|
||||||
startingAt: Duration? = nil
|
startingAt: Duration? = nil
|
||||||
) async -> PlaybackSource? {
|
) async -> PlaybackSource? {
|
||||||
let start = startingAt ?? request.startTime
|
let start = startingAt ?? request.startTime
|
||||||
|
|
||||||
switch decision.method {
|
let transcode: TranscodeSettings? = switch decision.method {
|
||||||
case .directPlay:
|
case .directPlay: nil
|
||||||
guard let url = server.directPlayURL(part: part) else { return nil }
|
|
||||||
return PlaybackSource(url: url, origin: .remote, startTime: start)
|
|
||||||
|
|
||||||
case .transcode(let maximumBitrate):
|
case .transcode(let maximumBitrate):
|
||||||
let transcode = TranscodeRequest(
|
TranscodeSettings(maximumBitrate: maximumBitrate, resolution: quality.resolution)
|
||||||
ratingKey: item.ratingKey,
|
}
|
||||||
mediaIndex: request.mediaIndex,
|
|
||||||
maximumBitrate: maximumBitrate,
|
|
||||||
resolution: quality.resolution,
|
|
||||||
isLocal: isLocalConnection
|
|
||||||
)
|
|
||||||
transcodeSession = transcode.sessionIdentifier
|
|
||||||
|
|
||||||
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier
|
do {
|
||||||
// schon nein, steht der Grund im Klartext da — statt eines
|
let target = try await backend.preparePlayback(PlaybackIntent(
|
||||||
// schwarzen Bildes nach zwanzig Sekunden Warten.
|
itemID: item.id,
|
||||||
if let decision = try? await server.transcodeDecision(for: transcode),
|
versionIndex: request.mediaIndex,
|
||||||
!decision.isAllowed {
|
file: part,
|
||||||
phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused"))
|
transcode: transcode,
|
||||||
return nil
|
startTime: start,
|
||||||
}
|
isLocalConnection: isLocalConnection
|
||||||
|
))
|
||||||
guard let url = server.transcodeURL(for: transcode) else { return nil }
|
playbackSession = target.sessionID
|
||||||
return PlaybackSource(url: url, origin: .remote, startTime: start)
|
playbackVersionID = target.versionID
|
||||||
|
return PlaybackSource(url: target.url, origin: .remote, startTime: start)
|
||||||
|
} catch {
|
||||||
|
// Die Worte des Servers stehen auf dem Bildschirm, nicht „keine
|
||||||
|
// abspielbare Datei". Sagt er, warum er ablehnt, ist das die einzige
|
||||||
|
// brauchbare Spur — und genau sie ging vorher verloren.
|
||||||
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +458,7 @@ final class PlayerModel {
|
|||||||
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
|
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
|
||||||
/// nichts, was man bei jedem Antippen tun will.
|
/// nichts, was man bei jedem Antippen tun will.
|
||||||
func refreshLiveProgrammeIfNeeded() async {
|
func refreshLiveProgrammeIfNeeded() async {
|
||||||
guard isLive, let server else { return }
|
guard isLive, let server = liveTV else { return }
|
||||||
if let programmeEndsAt, programmeEndsAt > .now { return }
|
if let programmeEndsAt, programmeEndsAt > .now { return }
|
||||||
|
|
||||||
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
|
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
|
||||||
@@ -475,7 +501,7 @@ final class PlayerModel {
|
|||||||
func skipActiveMarker() {
|
func skipActiveMarker() {
|
||||||
guard let marker = activeMarker else { return }
|
guard let marker = activeMarker else { return }
|
||||||
skippedMarkers.insert(marker.id)
|
skippedMarkers.insert(marker.id)
|
||||||
seek(to: .milliseconds(marker.endTimeOffset))
|
seek(to: .milliseconds(marker.endMilliseconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectAudioTrack(_ track: MediaTrack) {
|
func selectAudioTrack(_ track: MediaTrack) {
|
||||||
@@ -520,14 +546,14 @@ final class PlayerModel {
|
|||||||
|
|
||||||
// Den Transcoder anhalten. Ohne dieses Signal rechnet der Server
|
// Den Transcoder anhalten. Ohne dieses Signal rechnet der Server
|
||||||
// minutenlang weiter, obwohl niemand mehr zusieht.
|
// minutenlang weiter, obwohl niemand mehr zusieht.
|
||||||
if let transcodeSession, let server {
|
if let playbackSession, let backend {
|
||||||
await server.stopTranscode(sessionIdentifier: transcodeSession)
|
await backend.endPlayback(sessionID: playbackSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware:
|
// Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware:
|
||||||
// vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten
|
// vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten
|
||||||
// Einschalten — bis der Server von selbst merkt, dass niemand zusieht.
|
// Einschalten — bis der Server von selbst merkt, dass niemand zusieht.
|
||||||
if let subscription = request.liveSubscriptionKey, let server {
|
if let subscription = request.liveSubscriptionKey, let server = liveTV {
|
||||||
await server.endLiveSession(subscriptionKey: subscription)
|
await server.endLiveSession(subscriptionKey: subscription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,6 +568,10 @@ final class PlayerModel {
|
|||||||
private func startReporting() {
|
private func startReporting() {
|
||||||
reportTask?.cancel()
|
reportTask?.cancel()
|
||||||
reportTask = Task { [weak self] in
|
reportTask = Task { [weak self] in
|
||||||
|
// Sofort, nicht erst nach zehn Sekunden: erst diese Meldung legt
|
||||||
|
// beim Server die Sitzung an, unter der alles Weitere läuft.
|
||||||
|
await self?.report(state: .started)
|
||||||
|
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
// Alle zehn Sekunden — so oft melden es auch die Apps von Plex
|
// Alle zehn Sekunden — so oft melden es auch die Apps von Plex
|
||||||
// selbst. Häufiger brächte nichts, seltener verlöre beim
|
// selbst. Häufiger brächte nichts, seltener verlöre beim
|
||||||
@@ -554,13 +584,28 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func report(state: PlaybackReportState) async {
|
private func report(state: PlaybackReportState) async {
|
||||||
guard let server, let item, let duration, currentTime > .zero else { return }
|
guard let backend, let item else { return }
|
||||||
await server.reportProgress(
|
|
||||||
ratingKey: item.ratingKey,
|
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
|
||||||
|
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
|
||||||
|
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
|
||||||
|
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
|
||||||
|
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
|
||||||
|
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
|
||||||
|
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
|
||||||
|
guard currentTime > .zero || state == .started else { return }
|
||||||
|
|
||||||
|
await backend.reportProgress(PlaybackReport(
|
||||||
|
itemID: item.id,
|
||||||
|
sessionID: playbackSession,
|
||||||
|
versionID: playbackVersionID,
|
||||||
state: state,
|
state: state,
|
||||||
time: currentTime,
|
time: currentTime,
|
||||||
duration: duration
|
duration: total
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Marken und nächste Folge
|
// MARK: - Marken und nächste Folge
|
||||||
@@ -570,9 +615,7 @@ final class PlayerModel {
|
|||||||
let milliseconds = currentTime.milliseconds
|
let milliseconds = currentTime.milliseconds
|
||||||
|
|
||||||
activeMarker = item.markers.first { marker in
|
activeMarker = item.markers.first { marker in
|
||||||
guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else {
|
guard marker.contains(milliseconds) else { return false }
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
||||||
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
||||||
// „Film beenden".
|
// „Film beenden".
|
||||||
@@ -587,9 +630,9 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func preloadNextEpisode() {
|
private func preloadNextEpisode() {
|
||||||
guard let item, item.kind == .episode, let server else { return }
|
guard let item, item.kind == .episode, let backend else { return }
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
let next = try? await server.nextEpisode(after: item)
|
let next = try? await backend.nextEpisode(after: item)
|
||||||
self?.nextEpisode = next
|
self?.nextEpisode = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,8 +642,8 @@ final class PlayerModel {
|
|||||||
func nextEpisodeRequest() -> PlaybackRequest? {
|
func nextEpisodeRequest() -> PlaybackRequest? {
|
||||||
guard let nextEpisode else { return nil }
|
guard let nextEpisode else { return nil }
|
||||||
return PlaybackRequest(
|
return PlaybackRequest(
|
||||||
ratingKey: nextEpisode.ratingKey,
|
ratingKey: nextEpisode.id,
|
||||||
title: nextEpisode.asMediaItem.playbackTitle,
|
title: nextEpisode.playbackTitle,
|
||||||
mediaIndex: 0,
|
mediaIndex: 0,
|
||||||
startTime: .zero
|
startTime: .zero
|
||||||
)
|
)
|
||||||
@@ -608,11 +651,12 @@ final class PlayerModel {
|
|||||||
|
|
||||||
// MARK: - Innereien
|
// MARK: - Innereien
|
||||||
|
|
||||||
private func version(of item: PlexItem) -> PlexMedia? {
|
private func version(of item: MediaItem) -> MediaVersion? {
|
||||||
guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia }
|
guard item.versions.indices.contains(request.mediaIndex) else { return item.preferredVersion }
|
||||||
return item.media[request.mediaIndex]
|
return item.versions[request.mediaIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
|
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
|
||||||
///
|
///
|
||||||
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
|
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
|
||||||
@@ -697,11 +741,8 @@ final class PlayerModel {
|
|||||||
// und der Auslöser fürs Nachladen: der Vorrat ist jetzt
|
// und der Auslöser fürs Nachladen: der Vorrat ist jetzt
|
||||||
// um eine Folge kleiner.
|
// um eine Folge kleiner.
|
||||||
downloads.markWatched(ratingKey: request.ratingKey)
|
downloads.markWatched(ratingKey: request.ratingKey)
|
||||||
Task { [server, downloads] in
|
Task { [backend, downloads] in
|
||||||
await DownloadRefiller(
|
await DownloadRefiller(backend: backend, coordinator: downloads).refill()
|
||||||
backend: server.map(PlexBackend.init),
|
|
||||||
coordinator: downloads
|
|
||||||
).refill()
|
|
||||||
}
|
}
|
||||||
case .tracksResolved(let audio, let subtitle):
|
case .tracksResolved(let audio, let subtitle):
|
||||||
audioTracks = audio
|
audioTracks = audio
|
||||||
@@ -727,7 +768,7 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playerSubtitle(for item: PlexItem) -> String? {
|
private func playerSubtitle(for item: MediaItem) -> String? {
|
||||||
guard item.kind == .episode else { return nil }
|
guard item.kind == .episode else { return nil }
|
||||||
if let season = item.parentIndex, let episode = item.index {
|
if let season = item.parentIndex, let episode = item.index {
|
||||||
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"
|
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"
|
||||||
|
|||||||
@@ -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`.
|
/// Jellyfin als `MediaBackend`.
|
||||||
public struct JellyfinBackend: MediaBackend {
|
public struct JellyfinBackend: MediaBackend {
|
||||||
private let client: JellyfinClient
|
let client: JellyfinClient
|
||||||
private let account: JellyfinAccount
|
let account: JellyfinAccount
|
||||||
private let images = JellyfinImagePaths()
|
private let images = JellyfinImagePaths()
|
||||||
public let serverName: String
|
public let serverName: String
|
||||||
|
|
||||||
@@ -251,12 +251,15 @@ public struct JellyfinBackend: MediaBackend {
|
|||||||
// MARK: - Herunterladen
|
// MARK: - Herunterladen
|
||||||
|
|
||||||
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
|
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(
|
return client.url(
|
||||||
path: "/Videos/\(sourceID)/stream",
|
path: "/Videos/\(itemID)/stream",
|
||||||
query: [
|
query: [
|
||||||
URLQueryItem(name: "static", value: "true"),
|
URLQueryItem(name: "static", value: "true"),
|
||||||
URLQueryItem(name: "mediaSourceId", value: sourceID),
|
URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID),
|
||||||
],
|
],
|
||||||
includingToken: account.token
|
includingToken: account.token
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ public actor JellyfinClient {
|
|||||||
self.token = token
|
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 {
|
public nonisolated func authorization(token: String?) -> String {
|
||||||
identity.authorizationHeader(token: token)
|
identity.authorizationHeader(token: token)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ extension JellyfinMediaSource {
|
|||||||
// trennt — hier ist die zweite eben einelementig.
|
// trennt — hier ist die zweite eben einelementig.
|
||||||
MediaFile(
|
MediaFile(
|
||||||
id: id ?? itemID,
|
id: id ?? itemID,
|
||||||
key: id,
|
key: id ?? itemID,
|
||||||
|
itemID: itemID,
|
||||||
container: container,
|
container: container,
|
||||||
size: size,
|
size: size,
|
||||||
duration: runTimeTicks?.millisecondsFromTicks,
|
duration: runTimeTicks?.millisecondsFromTicks,
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"strings": {
|
"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": {
|
"row.continueWatching": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -50,6 +66,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"search.episodes": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Folgen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Episodes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"search.movies": {
|
"search.movies": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"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?
|
resolution: String?
|
||||||
) -> URL?
|
) -> 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
|
// MARK: - Zustand melden
|
||||||
|
|
||||||
func markWatched(id: String) async throws(MediaBackendError)
|
func markWatched(id: String) async throws(MediaBackendError)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
/// Was beim Sprechen mit einem Server schiefgehen kann.
|
/// 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
|
/// sondern beschreiben die Lage, in der man steckt. Dass es sie jetzt nur
|
||||||
/// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in
|
/// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in
|
||||||
/// lesbaren Text stand an zwei Stellen, und die beiden waren bereits
|
/// 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)
|
case transport(description: String)
|
||||||
/// Der Server hat geantwortet, aber anders als erwartet.
|
/// Der Server hat geantwortet, aber anders als erwartet.
|
||||||
case decoding(description: String)
|
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.
|
/// Ein Abschnitt, den man überspringen möchte.
|
||||||
public struct MediaMarker: Sendable, Equatable {
|
public struct MediaMarker: Sendable, Equatable, Identifiable {
|
||||||
public enum Kind: String, Sendable {
|
public enum Kind: String, Sendable {
|
||||||
case intro, credits, commercial, unknown
|
case intro, credits, commercial, unknown
|
||||||
}
|
}
|
||||||
@@ -236,6 +236,17 @@ public struct MediaMarker: Sendable, Equatable {
|
|||||||
public func contains(_ milliseconds: Int) -> Bool {
|
public func contains(_ milliseconds: Int) -> Bool {
|
||||||
milliseconds >= startMilliseconds && milliseconds < endMilliseconds
|
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.
|
/// 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
|
public let id: String
|
||||||
/// Der Pfad, über den die Datei abgerufen wird.
|
/// Der Pfad, über den die Datei abgerufen wird.
|
||||||
public let key: String?
|
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?
|
public let container: String?
|
||||||
/// Größe in Bytes.
|
/// Größe in Bytes.
|
||||||
///
|
///
|
||||||
@@ -121,6 +129,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
|
|||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
key: String? = nil,
|
key: String? = nil,
|
||||||
|
itemID: String? = nil,
|
||||||
container: String? = nil,
|
container: String? = nil,
|
||||||
size: Int64? = nil,
|
size: Int64? = nil,
|
||||||
duration: Int? = nil,
|
duration: Int? = nil,
|
||||||
@@ -129,6 +138,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
|
|||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.key = key
|
self.key = key
|
||||||
|
self.itemID = itemID
|
||||||
self.container = container
|
self.container = container
|
||||||
self.size = size
|
self.size = size
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
|
|||||||
@@ -181,7 +181,11 @@ extension PlexServer {
|
|||||||
query: [
|
query: [
|
||||||
URLQueryItem(name: "ratingKey", value: ratingKey),
|
URLQueryItem(name: "ratingKey", value: ratingKey),
|
||||||
URLQueryItem(name: "key", value: "/library/metadata/\(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: "time", value: String(time.milliseconds)),
|
||||||
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
|
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
|
||||||
],
|
],
|
||||||
@@ -227,15 +231,6 @@ extension PlexServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// In welchem Zustand sich die Wiedergabe beim Melden befindet.
|
/// 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 {
|
extension PlexServer {
|
||||||
/// Die nächste Folge nach der angegebenen.
|
/// 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
|
// MARK: - Zustand melden
|
||||||
|
|
||||||
public func markWatched(id: String) async throws(MediaBackendError) {
|
public func markWatched(id: String) async throws(MediaBackendError) {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import JellyfinKit
|
||||||
|
|
||||||
|
/// Was die App dem Server zum Abspielen schickt — und was sie daraus macht.
|
||||||
|
///
|
||||||
|
/// Adressen und Anfragekörper entstehen ohne Netz und lassen sich deshalb
|
||||||
|
/// genau prüfen. Genau hier lagen bei Plex die Fehler, die am teuersten waren:
|
||||||
|
/// ein Wert, der nicht kodiert wurde, ein Feld, das der Server anders nennt.
|
||||||
|
@Suite("Wiedergabe bei Jellyfin")
|
||||||
|
struct PlaybackTests {
|
||||||
|
|
||||||
|
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
|
||||||
|
let client = JellyfinClient(
|
||||||
|
baseURL: URL(string: "http://server:8096")!,
|
||||||
|
identity: JellyfinIdentity(
|
||||||
|
client: "Flixy", device: "Testgerät", deviceID: "gerät-1", version: "1"
|
||||||
|
),
|
||||||
|
token: "tok",
|
||||||
|
transport: transport
|
||||||
|
)
|
||||||
|
return JellyfinBackend(
|
||||||
|
client: client,
|
||||||
|
account: JellyfinAccount(
|
||||||
|
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
|
||||||
|
),
|
||||||
|
serverName: "Attrappe"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var file: MediaFile {
|
||||||
|
MediaFile(id: "src-1", key: "src-1", container: "mp4")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unverändert: die Datei selbst, mit Token in der Adresse")
|
||||||
|
func buildsDirectPlayURL() async throws {
|
||||||
|
let target = try await backend(RecordingTransport())
|
||||||
|
.preparePlayback(PlaybackIntent(itemID: "film-1", versionIndex: 0, file: file))
|
||||||
|
|
||||||
|
let url = try #require(URLComponents(url: target.url, resolvingAgainstBaseURL: false))
|
||||||
|
#expect(url.path == "/Videos/film-1/stream")
|
||||||
|
// Der Systemplayer schickt keine Kopfzeilen mit. Steht der Token nicht
|
||||||
|
// in der Adresse, antwortet der Server mit „nicht angemeldet".
|
||||||
|
#expect(url.queryItems?.contains(URLQueryItem(name: "ApiKey", value: "tok")) == true)
|
||||||
|
#expect(url.queryItems?.contains(URLQueryItem(name: "static", value: "true")) == true)
|
||||||
|
#expect(url.queryItems?.contains(URLQueryItem(name: "mediaSourceId", value: "src-1")) == true)
|
||||||
|
// Auch ohne Umrechnung eine Sitzung: sonst ordnet Jellyfin den
|
||||||
|
// Fortschritt keiner Wiedergabe zu.
|
||||||
|
#expect(target.sessionID != nil)
|
||||||
|
#expect(target.versionID == "src-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Umgerechnet: die Adresse kommt vom Server")
|
||||||
|
func usesServerTranscodingURL() async throws {
|
||||||
|
let transport = RecordingTransport(response: """
|
||||||
|
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
|
||||||
|
"PlaySessionId": "ps-7"}
|
||||||
|
""")
|
||||||
|
|
||||||
|
let target = try await backend(transport).preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1",
|
||||||
|
versionIndex: 0,
|
||||||
|
file: file,
|
||||||
|
transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720")
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/main.m3u8"))
|
||||||
|
#expect(target.sessionID == "ps-7")
|
||||||
|
|
||||||
|
// Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu
|
||||||
|
// wenig, und aus vier Megabit werden vier Kilobit.
|
||||||
|
let asked = try #require(transport.lastRequest)
|
||||||
|
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
|
||||||
|
#expect(query.contains(URLQueryItem(name: "maxStreamingBitrate", value: "4000000")))
|
||||||
|
#expect(query.contains(URLQueryItem(name: "enableDirectPlay", value: "false")))
|
||||||
|
#expect(asked.httpMethod == "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Lehnt der Server ab, steht der Grund im Fehler")
|
||||||
|
func refusesWithoutTranscodingURL() async throws {
|
||||||
|
let transport = RecordingTransport(response: """
|
||||||
|
{"MediaSources": [{"Id": "src-1"}], "PlaySessionId": "ps-7"}
|
||||||
|
""")
|
||||||
|
|
||||||
|
await #expect(throws: MediaBackendError.self) {
|
||||||
|
try await backend(transport).preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1",
|
||||||
|
versionIndex: 0,
|
||||||
|
file: file,
|
||||||
|
transcode: TranscodeSettings(maximumBitrate: nil, resolution: nil)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Bildhöhe ersetzt eine vorhandene Angabe, statt daneben zu stehen")
|
||||||
|
func replacesExistingHeight() {
|
||||||
|
let url = URL(string: "http://s/main.m3u8?maxHeight=1080&videoCodec=h264")!
|
||||||
|
|
||||||
|
let limited = JellyfinBackend.limiting(height: "1280x720", in: url)
|
||||||
|
let query = URLComponents(url: limited, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||||
|
|
||||||
|
// Bei zwei gleichnamigen Werten gilt beim Server der erste — und das
|
||||||
|
// wäre dann gerade nicht der gewählte.
|
||||||
|
#expect(query.filter { $0.name.lowercased() == "maxheight" }.count == 1)
|
||||||
|
#expect(query.contains(URLQueryItem(name: "maxHeight", value: "720")))
|
||||||
|
#expect(query.contains(URLQueryItem(name: "videoCodec", value: "h264")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne gewählte Auflösung bleibt die Adresse, wie sie ist")
|
||||||
|
func leavesURLAloneWithoutResolution() {
|
||||||
|
let url = URL(string: "http://s/main.m3u8?videoCodec=h264")!
|
||||||
|
|
||||||
|
#expect(JellyfinBackend.limiting(height: nil, in: url) == url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Fortschritt geht in Ticks und mit allen Kennungen hinaus")
|
||||||
|
func reportsProgressInTicks() async throws {
|
||||||
|
let transport = RecordingTransport()
|
||||||
|
|
||||||
|
await backend(transport).reportProgress(PlaybackReport(
|
||||||
|
itemID: "film-1",
|
||||||
|
sessionID: "ps-7",
|
||||||
|
versionID: "src-1",
|
||||||
|
state: .playing,
|
||||||
|
time: .seconds(90),
|
||||||
|
duration: .seconds(600)
|
||||||
|
))
|
||||||
|
|
||||||
|
let asked = try #require(transport.lastRequest)
|
||||||
|
#expect(asked.url?.path == "/Sessions/Playing/Progress")
|
||||||
|
|
||||||
|
let body = try #require(asked.httpBody)
|
||||||
|
let fields = try #require(
|
||||||
|
try JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||||
|
)
|
||||||
|
// 90 Sekunden sind 900.000.000 Ticks. Zehntausend je Millisekunde.
|
||||||
|
#expect(fields["PositionTicks"] as? Int == 900_000_000)
|
||||||
|
#expect(fields["PlaySessionId"] as? String == "ps-7")
|
||||||
|
#expect(fields["MediaSourceId"] as? String == "src-1")
|
||||||
|
#expect(fields["IsPaused"] as? Bool == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der erste Bericht legt die Sitzung an")
|
||||||
|
func reportsStartSeparately() async throws {
|
||||||
|
let transport = RecordingTransport()
|
||||||
|
|
||||||
|
await backend(transport).reportProgress(PlaybackReport(
|
||||||
|
itemID: "film-1", state: .started, time: .zero, duration: .seconds(600)
|
||||||
|
))
|
||||||
|
|
||||||
|
// Ohne diese Meldung erscheint der Titel nirgends als „läuft gerade",
|
||||||
|
// und der Server kennt die Sitzung nicht, unter der alles Weitere
|
||||||
|
// gemeldet wird.
|
||||||
|
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der letzte Bericht geht an einen eigenen Endpunkt")
|
||||||
|
func reportsStopSeparately() async throws {
|
||||||
|
let transport = RecordingTransport()
|
||||||
|
|
||||||
|
await backend(transport).reportProgress(PlaybackReport(
|
||||||
|
itemID: "film-1", state: .stopped, time: .seconds(5), duration: .seconds(600)
|
||||||
|
))
|
||||||
|
|
||||||
|
// „Gestoppt" als Fortschritt zu melden nähme den Titel nicht aus
|
||||||
|
// „wird gerade abgespielt" heraus.
|
||||||
|
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing/Stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Abräumen nennt Gerät und Sitzung")
|
||||||
|
func endsEncodingWithDeviceAndSession() async throws {
|
||||||
|
let transport = RecordingTransport()
|
||||||
|
|
||||||
|
await backend(transport).endPlayback(sessionID: "ps-7")
|
||||||
|
|
||||||
|
let asked = try #require(transport.lastRequest)
|
||||||
|
#expect(asked.httpMethod == "DELETE")
|
||||||
|
#expect(asked.url?.path == "/Videos/ActiveEncodings")
|
||||||
|
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
|
||||||
|
// Über die Sitzung allein hält Jellyfin nichts an.
|
||||||
|
#expect(query.contains(URLQueryItem(name: "deviceId", value: "gerät-1")))
|
||||||
|
#expect(query.contains(URLQueryItem(name: "playSessionId", value: "ps-7")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Transport, der nichts verschickt und alles behält.
|
||||||
|
private final class RecordingTransport: JellyfinTransport, @unchecked Sendable {
|
||||||
|
private let response: String
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var recorded: URLRequest?
|
||||||
|
|
||||||
|
init(response: String = "{}") {
|
||||||
|
self.response = response
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastRequest: URLRequest? {
|
||||||
|
lock.withLock { recorded }
|
||||||
|
}
|
||||||
|
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
lock.withLock { recorded = request }
|
||||||
|
return (
|
||||||
|
Data(response.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,5 +18,17 @@ entsteht. Das ist Absicht: so lässt sich auf einen Blick erkennen, ob der
|
|||||||
Aufmacher tatsächlich weiterwandert und ob eine Kachel das Bild zeigt, das zu
|
Aufmacher tatsächlich weiterwandert und ob eine Kachel das Bild zeigt, das zu
|
||||||
ihr gehört.
|
ihr gehört.
|
||||||
|
|
||||||
|
Abgespielt wird `sample.mp4` — zehn Sekunden Farbverlauf, mit einem kleinen
|
||||||
|
Swift-Programm erzeugt. Der Server beantwortet Bereichsanfragen, weil der
|
||||||
|
Systemplayer eine Datei nie am Stück holt.
|
||||||
|
|
||||||
|
Was hereinkommt, steht unter `http://127.0.0.1:8096/__reports`: jede
|
||||||
|
Wiedergabemeldung mit ihrem Anfragekörper. Damit lässt sich prüfen, ob die App
|
||||||
|
den Beginn meldet, ob der Fortschritt in Ticks ankommt und ob am Ende
|
||||||
|
abgeräumt wird.
|
||||||
|
|
||||||
|
Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
|
||||||
|
wie bei einem Server, der eine Datei noch nicht untersucht hat.
|
||||||
|
|
||||||
Kein echter Server wird dabei angefasst, und es gehen keine Zugangsdaten über
|
Kein echter Server wird dabei angefasst, und es gehen keine Zugangsdaten über
|
||||||
die Leitung.
|
die Leitung.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,11 +1,18 @@
|
|||||||
"""Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und
|
"""Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und
|
||||||
stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt."""
|
stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt."""
|
||||||
import json, struct, zlib, hashlib
|
import json, os, struct, zlib, hashlib
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
USER_ID = "user-1"
|
USER_ID = "user-1"
|
||||||
TOKEN = "stub-token"
|
TOKEN = "stub-token"
|
||||||
|
SAMPLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sample.mp4")
|
||||||
|
# Was an Wiedergabemeldungen hereinkam — damit sich prüfen lässt, ob die App
|
||||||
|
# den Fortschritt tatsächlich meldet.
|
||||||
|
REPORTS = []
|
||||||
|
# Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App
|
||||||
|
# auf Umrechnen, und der Weg über PlaybackInfo lässt sich prüfen.
|
||||||
|
UNANALYSED = "--unanalysed" in os.sys.argv
|
||||||
|
|
||||||
TITLES = [
|
TITLES = [
|
||||||
("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."),
|
("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."),
|
||||||
@@ -27,6 +34,22 @@ def item(key, name, kind, overview, *, resume=None, series=None):
|
|||||||
"BackdropImageTags": ["b-" + key],
|
"BackdropImageTags": ["b-" + key],
|
||||||
"Genres": ["Drama"],
|
"Genres": ["Drama"],
|
||||||
"UserData": {"PlayCount": 0, "Played": False, "PlaybackPositionTicks": 0},
|
"UserData": {"PlayCount": 0, "Played": False, "PlaybackPositionTicks": 0},
|
||||||
|
"MediaSources": [{
|
||||||
|
"Id": "src-" + key,
|
||||||
|
"Container": "mp4",
|
||||||
|
"Size": 28135,
|
||||||
|
"Bitrate": 1_200_000,
|
||||||
|
"RunTimeTicks": 10 * 10_000_000,
|
||||||
|
"SupportsDirectPlay": True,
|
||||||
|
"SupportsDirectStream": True,
|
||||||
|
"SupportsTranscoding": True,
|
||||||
|
"MediaStreams": [] if UNANALYSED else [
|
||||||
|
{"Index": 0, "Type": "Video", "Codec": "h264",
|
||||||
|
"Width": 640, "Height": 360},
|
||||||
|
{"Index": 1, "Type": "Audio", "Codec": "aac", "Channels": 2,
|
||||||
|
"Language": "ger", "DisplayTitle": "Deutsch", "IsDefault": True},
|
||||||
|
],
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
if resume:
|
if resume:
|
||||||
d["UserData"]["PlaybackPositionTicks"] = int(d["RunTimeTicks"] * resume)
|
d["UserData"]["PlaybackPositionTicks"] = int(d["RunTimeTicks"] * resume)
|
||||||
@@ -106,6 +129,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.",
|
item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.",
|
||||||
series=("s1", "Küstenwache", 4)),
|
series=("s1", "Küstenwache", 4)),
|
||||||
]))
|
]))
|
||||||
|
if p.startswith("/Videos/") and p.endswith("/stream"):
|
||||||
|
return self.serve_file(SAMPLE, "video/mp4")
|
||||||
|
if p.startswith(f"/Users/{USER_ID}/Items/") and p.count("/") == 4:
|
||||||
|
key = p.rsplit("/", 1)[-1]
|
||||||
|
match = next((t for t in TITLES if t[0] == key), None)
|
||||||
|
if match:
|
||||||
|
return self.reply(item(*match))
|
||||||
|
return self.reply(item(key, "Unbekannt", "Movie", "Aus dem Nichts."))
|
||||||
if p.startswith("/Items/") and "/Images/" in p:
|
if p.startswith("/Items/") and "/Images/" in p:
|
||||||
key = p.split("/")[2]
|
key = p.split("/")[2]
|
||||||
return self.reply(png(colour_for(key)), "image/png")
|
return self.reply(png(colour_for(key)), "image/png")
|
||||||
@@ -114,20 +145,65 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
kind = "Series" if parent == "lib-shows" else "Movie"
|
kind = "Series" if parent == "lib-shows" else "Movie"
|
||||||
chosen = [t for t in TITLES if t[2] == kind] or TITLES
|
chosen = [t for t in TITLES if t[2] == kind] or TITLES
|
||||||
return self.reply(page([item(*t) for t in chosen]))
|
return self.reply(page([item(*t) for t in chosen]))
|
||||||
|
if p == "/__reports":
|
||||||
|
return self.reply(REPORTS)
|
||||||
if p == "/Genres":
|
if p == "/Genres":
|
||||||
return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}]))
|
return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}]))
|
||||||
return self.reply(page([]))
|
return self.reply(page([]))
|
||||||
|
|
||||||
|
def serve_file(self, path, content_type):
|
||||||
|
"""Mit Bereichsanfragen — AVPlayer holt eine Datei nie am Stück."""
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
data = handle.read()
|
||||||
|
rng = self.headers.get("Range")
|
||||||
|
start, end = 0, len(data) - 1
|
||||||
|
status = 200
|
||||||
|
if rng and rng.startswith("bytes="):
|
||||||
|
piece = rng[6:].split("-")
|
||||||
|
start = int(piece[0] or 0)
|
||||||
|
if len(piece) > 1 and piece[1]:
|
||||||
|
end = min(int(piece[1]), len(data) - 1)
|
||||||
|
status = 206
|
||||||
|
body = data[start:end + 1]
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Accept-Ranges", "bytes")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
if status == 206:
|
||||||
|
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
REPORTS.append(("encoding-stopped", self.path))
|
||||||
|
self.reply({})
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
url = urlparse(self.path)
|
url = urlparse(self.path)
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
self.rfile.read(length)
|
body = self.rfile.read(length)
|
||||||
if url.path in ("/Users/AuthenticateWithQuickConnect", "/Users/AuthenticateByName"):
|
if url.path in ("/Users/AuthenticateWithQuickConnect", "/Users/AuthenticateByName"):
|
||||||
return self.reply({
|
return self.reply({
|
||||||
"AccessToken": TOKEN,
|
"AccessToken": TOKEN,
|
||||||
"User": {"Id": USER_ID, "Name": "Testkonto",
|
"User": {"Id": USER_ID, "Name": "Testkonto",
|
||||||
"Policy": {"IsAdministrator": True}},
|
"Policy": {"IsAdministrator": True}},
|
||||||
})
|
})
|
||||||
|
if url.path.startswith("/Sessions/Playing"):
|
||||||
|
REPORTS.append((url.path, body.decode() if body else ""))
|
||||||
|
return self.reply({})
|
||||||
|
if url.path.startswith("/Items/") and url.path.endswith("/PlaybackInfo"):
|
||||||
|
key = url.path.split("/")[2]
|
||||||
|
# Keine echte Umrechnung — die Adresse zeigt auf dieselbe Datei.
|
||||||
|
# Geprüft wird hier der Weg, nicht der Transcoder.
|
||||||
|
return self.reply({
|
||||||
|
"MediaSources": [{
|
||||||
|
"Id": "src-" + key,
|
||||||
|
"Container": "mp4",
|
||||||
|
"SupportsTranscoding": True,
|
||||||
|
"TranscodingUrl": f"/Videos/{key}/stream?static=true&ApiKey={TOKEN}",
|
||||||
|
}],
|
||||||
|
"PlaySessionId": "ps-" + key,
|
||||||
|
})
|
||||||
if url.path == "/QuickConnect/Initiate":
|
if url.path == "/QuickConnect/Initiate":
|
||||||
POLLS["count"] = 0
|
POLLS["count"] = 0
|
||||||
return self.reply({"Secret": "s3cr3t", "Code": "428193"})
|
return self.reply({"Secret": "s3cr3t", "Code": "428193"})
|
||||||
|
|||||||
Reference in New Issue
Block a user