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>
266 lines
9.0 KiB
Swift
266 lines
9.0 KiB
Swift
import Foundation
|
||
|
||
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
|
||
///
|
||
/// Die Form, in der die Oberfläche Titel sieht, egal von welchem Server sie
|
||
/// stammen. Plex und Jellyfin liefern dieselben Dinge unter anderen Namen;
|
||
/// jeder Server-Adapter übersetzt einmal hierher, und danach ist die Herkunft
|
||
/// ohne Belang.
|
||
///
|
||
/// Die Namen sind dort geblieben, wo sie ohnehin neutral waren — `title`,
|
||
/// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist:
|
||
/// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und
|
||
/// aus dem „Großelternteil" ist die Serie geworden, die damit gemeint war.
|
||
public struct MediaItem: Sendable, Equatable, Identifiable {
|
||
public enum Kind: String, Sendable, Codable, CaseIterable {
|
||
case movie, show, season, episode, clip, collection
|
||
}
|
||
|
||
/// Die Kennung, unter der der Server den Eintrag führt.
|
||
///
|
||
/// Bei Plex eine Zahl als Zeichenkette, bei Jellyfin eine GUID. Für die
|
||
/// App ist beides nur ein undurchsichtiger Schlüssel — **außer** beim
|
||
/// Herunterladen, wo er mit einem Vorsatz versehen wird, damit sich zwei
|
||
/// Server nicht ins Gehege kommen.
|
||
public let id: String
|
||
/// Der serverinterne Pfad, falls einer gebraucht wird.
|
||
public let path: String?
|
||
public let kind: Kind?
|
||
public let title: String
|
||
public let sortTitle: String?
|
||
public let summary: String?
|
||
public let year: Int?
|
||
/// Laufzeit in Millisekunden.
|
||
public let duration: Int?
|
||
public let contentRating: String?
|
||
/// Bewertung der Kritik, 0–10.
|
||
public let rating: Double?
|
||
/// Bewertung des Publikums, 0–10.
|
||
public let audienceRating: Double?
|
||
|
||
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
|
||
// beim Abruf, weil sie Token und Größenangaben braucht.
|
||
public let thumb: String?
|
||
public let art: String?
|
||
/// Das Bild der Staffel.
|
||
public let parentThumb: String?
|
||
/// Das Bild der Serie. Bei Plex hieß das „grandparent" — gemeint war immer
|
||
/// die Serie, und so heißt es jetzt auch.
|
||
public let seriesThumb: String?
|
||
public let seriesArt: String?
|
||
|
||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||
// Staffel.
|
||
public let index: Int?
|
||
public let parentIndex: Int?
|
||
public let parentTitle: String?
|
||
public let parentID: String?
|
||
public let seriesTitle: String?
|
||
public let seriesID: String?
|
||
|
||
// Fortschritt.
|
||
public let playCount: Int?
|
||
/// Wie weit gesehen wurde, in Millisekunden.
|
||
public let resumeMilliseconds: Int?
|
||
public let lastPlayedAt: Date?
|
||
public let addedAt: Date?
|
||
/// Ob der Titel als Favorit markiert ist.
|
||
///
|
||
/// Plex kennt das nicht und meldet immer `false`; Jellyfin führt eine
|
||
/// eigene Liste. Ein Feld, das ein Server nicht füllt, ist ehrlicher als
|
||
/// eine Fähigkeit, die es nur auf einem gibt.
|
||
public let isFavourite: Bool
|
||
|
||
// Zählwerte bei Serien und Staffeln.
|
||
public let episodeCount: Int?
|
||
public let playedEpisodeCount: Int?
|
||
public let childCount: Int?
|
||
|
||
/// Die Fassungen, in denen der Titel vorliegt.
|
||
public let versions: [MediaVersion]
|
||
|
||
// Schlagwörter. Beide Server liefern sie nur bei der Einzelabfrage mit,
|
||
// nicht in Listen — dort sind sie deshalb leer.
|
||
public let genres: [String]
|
||
public let directors: [String]
|
||
public let writers: [String]
|
||
public let cast: [MediaPerson]
|
||
public let markers: [MediaMarker]
|
||
/// Zusätzliche Bilder, etwa der Schriftzug eines Films.
|
||
public let artwork: [MediaArtwork]
|
||
|
||
public init(
|
||
id: String,
|
||
path: String? = nil,
|
||
kind: Kind? = nil,
|
||
title: String,
|
||
sortTitle: String? = nil,
|
||
summary: String? = nil,
|
||
year: Int? = nil,
|
||
duration: Int? = nil,
|
||
contentRating: String? = nil,
|
||
rating: Double? = nil,
|
||
audienceRating: Double? = nil,
|
||
thumb: String? = nil,
|
||
art: String? = nil,
|
||
parentThumb: String? = nil,
|
||
seriesThumb: String? = nil,
|
||
seriesArt: String? = nil,
|
||
index: Int? = nil,
|
||
parentIndex: Int? = nil,
|
||
parentTitle: String? = nil,
|
||
parentID: String? = nil,
|
||
seriesTitle: String? = nil,
|
||
seriesID: String? = nil,
|
||
playCount: Int? = nil,
|
||
resumeMilliseconds: Int? = nil,
|
||
lastPlayedAt: Date? = nil,
|
||
addedAt: Date? = nil,
|
||
isFavourite: Bool = false,
|
||
episodeCount: Int? = nil,
|
||
playedEpisodeCount: Int? = nil,
|
||
childCount: Int? = nil,
|
||
versions: [MediaVersion] = [],
|
||
genres: [String] = [],
|
||
directors: [String] = [],
|
||
writers: [String] = [],
|
||
cast: [MediaPerson] = [],
|
||
markers: [MediaMarker] = [],
|
||
artwork: [MediaArtwork] = []
|
||
) {
|
||
self.id = id
|
||
self.path = path
|
||
self.kind = kind
|
||
self.title = title
|
||
self.sortTitle = sortTitle
|
||
self.summary = summary
|
||
self.year = year
|
||
self.duration = duration
|
||
self.contentRating = contentRating
|
||
self.rating = rating
|
||
self.audienceRating = audienceRating
|
||
self.thumb = thumb
|
||
self.art = art
|
||
self.parentThumb = parentThumb
|
||
self.seriesThumb = seriesThumb
|
||
self.seriesArt = seriesArt
|
||
self.index = index
|
||
self.parentIndex = parentIndex
|
||
self.parentTitle = parentTitle
|
||
self.parentID = parentID
|
||
self.seriesTitle = seriesTitle
|
||
self.seriesID = seriesID
|
||
self.playCount = playCount
|
||
self.resumeMilliseconds = resumeMilliseconds
|
||
self.lastPlayedAt = lastPlayedAt
|
||
self.addedAt = addedAt
|
||
self.isFavourite = isFavourite
|
||
self.episodeCount = episodeCount
|
||
self.playedEpisodeCount = playedEpisodeCount
|
||
self.childCount = childCount
|
||
self.versions = versions
|
||
self.genres = genres
|
||
self.directors = directors
|
||
self.writers = writers
|
||
self.cast = cast
|
||
self.markers = markers
|
||
self.artwork = artwork
|
||
}
|
||
}
|
||
|
||
extension MediaItem {
|
||
/// Wie weit gesehen — als Anteil zwischen 0 und 1.
|
||
///
|
||
/// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent
|
||
/// sieht aus wie ein Fehler, nicht wie „noch nicht angefangen".
|
||
public var watchProgress: Double? {
|
||
guard let resumeMilliseconds, let duration, duration > 0, resumeMilliseconds > 0 else {
|
||
return nil
|
||
}
|
||
return min(Double(resumeMilliseconds) / Double(duration), 1)
|
||
}
|
||
|
||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||
|
||
/// Ab wo fortgesetzt wird.
|
||
public var resumeTime: Duration {
|
||
.milliseconds(resumeMilliseconds ?? 0)
|
||
}
|
||
|
||
/// Die Fassung, die gespielt werden soll, wenn niemand wählt.
|
||
public var preferredVersion: MediaVersion? { versions.first }
|
||
|
||
public var hasMultipleVersions: Bool { versions.count > 1 }
|
||
|
||
/// Der Schriftzug des Films, falls der Server einen hat.
|
||
///
|
||
/// Viele Filme bringen ihr eigenes Logo mit — Harry Potter in seiner
|
||
/// Schrift sagt mehr als derselbe Titel in der Systemschrift.
|
||
public var clearLogoPath: String? {
|
||
artwork.first { $0.kind == .clearLogo }?.path
|
||
}
|
||
}
|
||
|
||
/// Jemand, der mitgewirkt hat.
|
||
public struct MediaPerson: Sendable, Equatable, Identifiable {
|
||
public let id: String
|
||
public let name: String
|
||
/// Die Rolle, sofern es eine gibt.
|
||
public let role: String?
|
||
public let thumb: String?
|
||
|
||
public init(id: String, name: String, role: String? = nil, thumb: String? = nil) {
|
||
self.id = id
|
||
self.name = name
|
||
self.role = role
|
||
self.thumb = thumb
|
||
}
|
||
}
|
||
|
||
/// Ein Abschnitt, den man überspringen möchte.
|
||
public struct MediaMarker: Sendable, Equatable, Identifiable {
|
||
public enum Kind: String, Sendable {
|
||
case intro, credits, commercial, unknown
|
||
}
|
||
|
||
public let kind: Kind
|
||
public let startMilliseconds: Int
|
||
public let endMilliseconds: Int
|
||
|
||
public init(kind: Kind, startMilliseconds: Int, endMilliseconds: Int) {
|
||
self.kind = kind
|
||
self.startMilliseconds = startMilliseconds
|
||
self.endMilliseconds = endMilliseconds
|
||
}
|
||
|
||
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.
|
||
public struct MediaArtwork: Sendable, Equatable {
|
||
public enum Kind: String, Sendable {
|
||
case poster, background, banner, clearLogo, unknown
|
||
}
|
||
|
||
public let kind: Kind
|
||
public let path: String
|
||
|
||
public init(kind: Kind, path: String) {
|
||
self.kind = kind
|
||
self.path = path
|
||
}
|
||
}
|