Files
Flaro/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift
T
ScarriffleandClaude Opus 5 3a6a6c4242 Intro und Abspann bei Jellyfin, und drei Dinge aus dem Gebrauch
**Intro überspringen gab es für Jellyfin nie.** Der Knopf, die Marken,
das automatische Überspringen, die Einstellungen dazu — alles stand seit
Langem im Player und auf dem Fernseher. Nur kam bei Jellyfin nie eine
Marke an: `JellyfinItem+Media.swift` setzte `markers: []`, weil Jellyfin
sie nicht am Titel liefert.

Seit 10.10 gibt es `/MediaSegments/{itemId}`, gefüllt von einem Anbieter
im Server wie dem Intro-Skipper. Die Anfrage läuft **neben** dem Abruf
des Titels, nicht danach — sonst wartete die Detailseite zweimal.

Zwei Dinge, die sonst eine Stunde kosten: `includeSegmentTypes` muss
**zweimal** stehen, nicht einmal mit Komma (mit Komma antwortet Jellyfin
mit null Treffern), und die Abfrage wirft nicht. Ein Server ohne Anbieter
antwortet leer, einer vor 10.10 mit einem 404 — und ein Film ohne Intro
ist kein Fehler, sondern der Normalfall.

Geprüft im Simulator: die Attrappe meldet jetzt Segmente, und im Player
steht „Intro überspringen".

---

**„Aus Weiterschauen entfernen".** Eine halbe Stunde gesehen und keine
Lust mehr — der Titel ist weder gesehen noch ungesehen, er soll nur nicht
ewig oben stehen. Beide Server können es, auf verschiedenen Wegen:

- **Plex** hat einen eigenen: `PUT /actions/removeFromContinueWatching`.
  **Nicht** `/:/unscrobble` — das setzt zugleich auf ungesehen.
- **Jellyfin** hat keinen. Was es gibt, ist das Benutzerdatenblatt; die
  Liste führt, was eine gemerkte Stelle grösser null hat. Also wird das
  Blatt gelesen, bekommt eine Null und geht so, wie es war, zurück.
  **Erst lesen, dann schreiben**, weil dort auch „gesehen", „Favorit" und
  die Bewertung stehen — wer seinen Favoriten verliert, weil er einen
  Film aus einer Liste genommen hat, sucht den Grund nie dort.

Der Eintrag erscheint nur, wenn es eine gemerkte Stelle gibt.

---

**Die Mediatheken für den Aufmacher taten zu wenig.** Die Regel „Reihen
ohne Mediathek bleiben immer drin" war freundlich gemeint und falsch: bei
„nur Filme" stand weiter eine Serie oben, weil sie aus „Weiterschauen"
kam. Gemessen und nicht vermutet — es lag nicht am Zeitfenster der
Auswahl, wie zuerst angenommen.

Jetzt gilt: ist etwas gewählt, speist sich der Aufmacher **nur** daraus.
Wer Weiterschauen oben haben will, wählt keine Mediathek. Der Hinweis
unter der Auswahl sagt das auch.

---

**Der „Bearbeiten"-Knopf stand über allem und tat dort nichts.** Er gilt
nur für die Reihen; jetzt steht er an deren Überschrift.

---

Die Attrappe kann drei Dinge mehr, sonst wäre nichts davon prüfbar:
Segmente, `parentId` bei den Neuzugängen (vorher stand in jeder Mediathek
dasselbe) und damit überhaupt eine Mediathekenauswahl, die man ansehen
kann.

iOS und tvOS bauen, 468 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 08:47:06 +02:00

203 lines
8.3 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 {
/// `markers` kommen von aussen, weil Jellyfin sie nicht am Titel
/// liefert: sie stehen hinter `/MediaSegments/{id}` und damit hinter
/// einer zweiten Anfrage. Siehe `JellyfinBackend.segments(forItem:)`.
public func asMediaItem(
imageBase: JellyfinImagePaths,
markers: [MediaMarker] = []
) -> 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,
// **Nur wenn es auch eines gibt** — dieselbe Vorsicht wie eine
// Zeile tiefer beim Hintergrundbild, die hier gefehlt hat.
//
// Die Adresse entsteht aus der Kennung, nicht aus einer Antwort
// des Servers. Ohne die Prüfung trug jeder Titel ohne eigenes
// Plakat eine Adresse, die mit „nicht gefunden" antwortet — und
// weil ein 404 zu Recht nicht wiederholt wird, blieb die Kachel
// für immer grau. Betroffen waren vor allem Staffeln: die haben
// bei Jellyfin oft kein eigenes Bild, sondern leihen das der
// Serie. Ist hier nichts, darf die Kachel genau das tun.
thumb: imageTags?["Primary"].map { imageBase.primary(itemID: id, tag: $0) },
// 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: 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,
title: title,
isDefault: isDefault ?? false,
isHearingImpaired: isHearingImpaired ?? false,
isOriginal: isOriginal ?? 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
}
}