Files
Flaro/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift
T
ScarriffleandClaude Opus 5 ba6be4ac79 MediaKit: die neutralen Datensätze und die Übersetzung aus Plex
`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>
2026-09-13 12:33:55 +02:00

255 lines
8.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}