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, Codable { 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 /// Ob der Server den Titel als angefangen führt. /// /// **Die Entscheidung gehört dem Server, nicht der Beschriftung.** „Hat /// eine Stelle über null" und „ist angefangen" waren dasselbe, bis es eine /// einstellbare Untergrenze gab. Seitdem heisst ein Titel, in den jemand /// eine Sekunde hineingeschaut hat, nicht mehr „Weiterschauen" — er steht /// ja auch nicht mehr in der Reihe. /// /// `nil` heisst: der Server sagt nichts dazu (Jellyfin, oder ein älterer /// Flaro). Dann gilt wie früher die blosse Stelle. public let isInProgress: Bool? /// Ab wann es den Titel gibt — `nil` heisst: jetzt. /// /// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das /// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses /// Feld bot die App „Abspielen" an und bekam vom Server eine Absage — /// ein Knopf, der ein Versprechen gibt und es zurücknimmt. public let availableFrom: Date? // 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, isInProgress: Bool? = nil, availableFrom: Date? = nil, 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.isInProgress = isInProgress self.availableFrom = availableFrom 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 { /// Dasselbe ohne alles, was eine Kachel nicht zeigt. /// /// **Wozu.** Ein Titel aus einer Listenabfrage bringt seine Fassungen mit, /// und die bringen jede Spur jeder Datei mit — Codec, Sprache, Kanäle. Im /// Raster steht davon nichts: dort stehen Plakat, Titel, Untertitel und /// der Fortschrittsbalken. Gespeichert wäre der Rest das Zwanzigfache an /// Daten, die bei jedem Aufrufen gelesen und ausgepackt werden müssten — /// und damit wäre der Speicher genau das nicht mehr, wofür es ihn gibt. /// /// **Warum das gefahrlos ist.** Aus einer Kachel führt nur ein Weg, und /// der geht über die Kennung: die Detailansicht holt den Titel neu. Was /// hier fehlt, fehlt nur so lange, bis der Server geantwortet hat. var listEssentials: MediaItem { MediaItem( id: id, path: path, kind: kind, title: title, sortTitle: sortTitle, year: year, duration: duration, contentRating: contentRating, thumb: thumb, art: art, parentThumb: parentThumb, seriesThumb: seriesThumb, index: index, parentIndex: parentIndex, parentTitle: parentTitle, parentID: parentID, seriesTitle: seriesTitle, seriesID: seriesID, playCount: playCount, resumeMilliseconds: resumeMilliseconds, lastPlayedAt: lastPlayedAt, addedAt: addedAt, isFavourite: isFavourite, episodeCount: episodeCount, playedEpisodeCount: playedEpisodeCount, childCount: childCount ) } /// 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 } /// Ob die Beschriftung „Weiterschauen" heissen soll. /// /// Eine Stelle, die beide Fälle kennt: was der Server sagt, und was ohne /// seine Auskunft gilt. Stünde der Zweifel an jedem Knopf einzeln, hiesse /// derselbe Titel auf der Startseite anders als auf seiner Seite. public var isResumable: Bool { isInProgress ?? (watchProgress != nil) } /// Der Balken unter der Kachel — nur, wenn der Titel wirklich angefangen ist. /// /// **Sonst sagt die Kachel etwas anderes als der Knopf.** Ein Strich unter /// dem Plakat heisst „du bist mittendrin". Für eine Sekunde, in die jemand /// hineingeschaut hat, ist das keine Auskunft, sondern ein Fussel. public var resumeProgress: Double? { isResumable ? watchProgress : nil } /// Ob der Titel erst noch kommt. /// /// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an /// einem Bildschirm „Abspielen" und am nächsten der Termin. public var isUpcoming: Bool { guard let availableFrom else { return false } return availableFrom > Date() } /// 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, Codable { 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, Identifiable, Codable { public enum Kind: String, Sendable, Codable { 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 } /// Der Anfang taugt als Kennung. /// /// Zwei Marken derselben Sendung können nicht an derselben Stelle /// beginnen, und der Player braucht die Kennung nur, um sich zu merken, /// was er schon übersprungen hat — ohne dieses Gedächtnis spränge ein /// Rücksprung ins Intro es sofort wieder weg. public var id: Int { startMilliseconds } public var isIntro: Bool { kind == .intro } public var isCredits: Bool { kind == .credits } } /// Ein zusätzliches Bild zu einem Titel. public struct MediaArtwork: Sendable, Equatable, Codable { public enum Kind: String, Sendable, Codable { 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 } }