**`LanguageCode`** bringt Kürzel auf eine vergleichbare Form. Der Anlass ist ein stiller Fehler: verglichen wurden die ersten zwei Zeichen, also `ge` gegen `de` — und `ger` ist die ISO-639-2/B-Form von Deutsch, die ffmpeg in viele Dateien schreibt. Bei solchen Titeln wirkte die Spracheinstellung überhaupt nicht. `gsw` bleibt `gsw`. Schweizerdeutsch ist keine Schreibweise von Deutsch, und es gleichzusetzen hiesse, jemandem Schweizerdeutsch unterzuschieben, der Hochdeutsch wollte. Es gehört in die Rangliste, nicht in eine Gleichsetzung. **`AudioTrackChooser`** wägt ab, statt zu filtern: jede Spur bekommt Punkte, die höchste gewinnt. Damit gibt es immer eine Antwort, und Audiodeskription wird gemieden, ohne unmöglich zu werden — gibt es eine Sprache nur mit Audiodeskription, ist sie besser als Stille. Gemerkt wird nicht eine Nummer, sondern eine Beschreibung der Spur: Folgen derselben Serie haben unterschiedlich sortierte Spuren, und „Spur 2" bedeutet in der nächsten Folge etwas anderes. Siebzehn Tests, darunter der ZDF-Fall wörtlich — deutsche Spur mit Audiodeskription als Standard, englisch betitelte deutsche Spur daneben — und zwar in beide Richtungen: mit Meiden gewinnt die richtige, ohne Meiden gewinnt das Kürzel. Sonst stünde der Schalter nur dabei. Dazu trägt `MediaStream` jetzt `title`, `isDefault`, `isHearingImpaired` und `isOriginal`. Der Name aus der Datei ist das zweite Signal neben dem Kürzel und manchmal das einzige richtige. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
8.0 KiB
Swift
197 lines
8.0 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,
|
|
// **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: [],
|
|
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
|
|
}
|
|
}
|