**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>
203 lines
8.3 KiB
Swift
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
|
|
}
|
|
}
|