`MediaItem` und die Typen darunter — die Form, in der die Oberfläche Titel sieht, unabhängig davon, welcher Server sie geliefert hat. Die Namen sind dort geblieben, wo sie ohnehin neutral waren: `title`, `summary`, `year`, `index`. Umbenannt wurde, was Plex-Jargon ist. `ratingKey` heißt `id`, `viewOffset` heißt `resumeMilliseconds`, und aus dem „Großelternteil" ist die Serie geworden, die von Anfang an gemeint war — ein Name, den man in jeder Ansicht im Kopf übersetzen musste. Zwei Dinge, die dabei ehrlicher werden: `isFavourite` gibt es, aber Plex meldet immer `false`. Ein Feld, das ein Server nicht füllt, ist besser als eine Fähigkeit, die es nur auf einem gibt und die sich nirgends setzen ließe. Dateigrößen sind jetzt `Int64`. Ein Remux überschreitet vier Gigabyte mühelos. Die Übersetzung steht in **einer** Datei und geht in **eine** Richtung. Die Drahtmodelle bleiben, wo sie sind — sie tragen Eigenheiten, die nur Plex hat, und die gehören nicht nach oben. Geprüft wird gegen echte Server-Antworten, nicht gegen erfundene Werte: genau dort stehen die Eigenheiten, an denen eine Übersetzung scheitert. Ein Test vergleicht außerdem beide Wege zum Wiedergabe-Profil — sagten sie Unterschiedliches, spielte ein Titel nach dem Umbau plötzlich über einen anderen Player. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
255 lines
8.5 KiB
Swift
255 lines
8.5 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 {
|
||
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
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|