Files
Flaro/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift
T
ScarriffleandClaude Opus 5 fb418c842d 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>
2026-09-13 14:00:02 +02:00

183 lines
7.2 KiB
Swift

import Foundation
import MediaKit
/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form.
///
/// Das Gegenstück zu `PlexItem+Media`. Beide Dateien machen dasselbe, und dass
/// es sie zweimal gibt, ist der Preis dafür, dass es die Ansichten nur einmal
/// gibt.
extension JellyfinItem {
public func asMediaItem(imageBase: JellyfinImagePaths) -> MediaItem {
MediaItem(
id: id,
path: nil,
kind: Self.kind(type),
title: name ?? "—",
summary: overview,
year: productionYear,
duration: runTimeTicks?.millisecondsFromTicks,
contentRating: officialRating,
// Jellyfin führt beide auf einer Zehnerskala, wie Plex auch.
rating: criticRating.map { $0 / 10 } ?? communityRating,
audienceRating: communityRating,
thumb: imageBase.primary(itemID: id, tag: imageTags?["Primary"]),
// Nur wenn es auch eines gibt: die Adresse entsteht aus der
// Kennung, nicht aus einer Antwort des Servers — ohne diese
// Prüfung stünde hinter jedem Titel ohne Hintergrundbild eine
// Adresse, die mit „nicht gefunden" antwortet. Fehlt dem Titel
// eines, nennt Jellyfin die Serie, von der es geliehen werden darf.
art: backdropImageTags?.first.map { imageBase.backdrop(itemID: id, tag: $0) }
?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) },
parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) },
seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) },
seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) },
index: indexNumber,
parentIndex: parentIndexNumber,
parentTitle: seasonName,
parentID: seasonID,
seriesTitle: seriesName,
seriesID: seriesID,
playCount: userData?.playCount,
resumeMilliseconds: userData?.playbackPositionTicks?.millisecondsFromTicks,
lastPlayedAt: nil,
addedAt: dateCreated,
// Das Feld, das es bei Plex nicht gibt — hier kommt es frei mit.
isFavourite: userData?.isFavorite ?? false,
episodeCount: recursiveItemCount,
playedEpisodeCount: playedCount,
childCount: childCount,
versions: (mediaSources ?? []).map { $0.asMediaVersion(itemID: id) },
genres: genres ?? [],
directors: (people ?? []).filter { $0.type == "Director" }.compactMap(\.name),
writers: (people ?? []).filter { $0.type == "Writer" }.compactMap(\.name),
cast: (people ?? []).filter { $0.type == "Actor" }.map { person in
MediaPerson(
id: person.id ?? person.name ?? UUID().uuidString,
name: person.name ?? "—",
role: person.role,
thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil
)
},
markers: [],
artwork: logoArtwork(imageBase: imageBase)
)
}
/// Wie viele Folgen gesehen sind.
///
/// Jellyfin meldet die **ungesehenen**. Die Zahl, die die Oberfläche
/// zeigt, ist die andere — und sie auszurechnen ist ehrlicher, als das
/// Gegenteil anzuzeigen und es „Fortschritt" zu nennen.
private var playedCount: Int? {
guard let total = recursiveItemCount, let unplayed = userData?.unplayedItemCount else {
return nil
}
return max(0, total - unplayed)
}
private func logoArtwork(imageBase: JellyfinImagePaths) -> [MediaArtwork] {
guard let tag = imageTags?["Logo"] else { return [] }
return [MediaArtwork(kind: .clearLogo, path: imageBase.logo(itemID: id, tag: tag))]
}
private static func kind(_ type: String?) -> MediaItem.Kind? {
switch type {
case "Movie": .movie
case "Series": .show
case "Season": .season
case "Episode": .episode
case "BoxSet": .collection
case "Trailer", "Video": .clip
default: nil
}
}
}
extension JellyfinMediaSource {
public func asMediaVersion(itemID: String) -> MediaVersion {
let streams = mediaStreams ?? []
let video = streams.first { $0.type == "Video" }
let audio = streams.first { $0.type == "Audio" && $0.isDefault == true }
?? streams.first { $0.type == "Audio" }
return MediaVersion(
id: id ?? itemID,
container: container,
videoCodec: video?.codec,
audioCodec: audio?.codec,
videoResolution: video?.height.map(String.init),
// Jellyfin rechnet in Bit je Sekunde, die App in Kilobit.
bitrate: bitrate.map { $0 / 1_000 },
width: video?.width,
height: video?.height,
audioChannels: audio?.channels,
duration: runTimeTicks?.millisecondsFromTicks,
files: [
// Jellyfin trennt Fassung und Datei nicht: eine Quelle ist
// beides. Die neutrale Form kennt beide Ebenen, weil Plex sie
// trennt — hier ist die zweite eben einelementig.
MediaFile(
id: id ?? itemID,
key: id ?? itemID,
itemID: itemID,
container: container,
size: size,
duration: runTimeTicks?.millisecondsFromTicks,
streams: streams.compactMap(\.asMediaStream)
)
]
)
}
}
extension JellyfinMediaStream {
public var asMediaStream: MediaStream? {
let mapped: MediaStream.Kind
switch type {
case "Video": mapped = .video
case "Audio": mapped = .audio
case "Subtitle": mapped = .subtitle
default: return nil
}
return MediaStream(
id: String(index ?? 0),
kind: mapped,
codec: codec,
language: language,
languageCode: language,
displayTitle: displayTitle,
channels: channels,
isSelected: isDefault ?? false,
isForced: isForced ?? false
)
}
}
/// Baut die Adressen der Bilder.
///
/// Eigener Typ, weil jedes Bild eine eigene Adresse ist und nicht ein Pfad wie
/// bei Plex. Die Marke (`tag`) gehört dazu: ohne sie liefert der Server zwar
/// ein Bild, aber der Zwischenspeicher merkt nicht, wenn es sich ändert.
public struct JellyfinImagePaths: Sendable {
public init() {}
public func primary(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Primary", tag: tag)
}
public func backdrop(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Backdrop", tag: tag)
}
public func logo(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Logo", tag: tag)
}
private func path(itemID: String, kind: String, tag: String?) -> String {
var path = "/Items/\(itemID)/Images/\(kind)"
if let tag { path += "?tag=\(tag)" }
return path
}
}