Files
Flaro/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift
T
GuidoandClaude Opus 5 bf0a63a924 Vier Punkte aus dem Praxistest
Download-Knopf tat nichts
  DownloadService.plan gab still nil zurück, wenn es keinen Auftrag
  bauen konnte, und download() reichte das als nil weiter. Der Knopf sah
  aus, als wäre er kaputt, dabei war der Grund jedes Mal bekannt. Es
  gibt jetzt einen eigenen Ablehnungsgrund mit vier unterschiedlichen
  Begründungen — keine Verbindung, keine Datei, kein abrufbarer Teil,
  keine Adresse. Eine Funktion, die kommentarlos nichts tut, ist selbst
  der Fehler.

Live-TV galt als „nicht eingerichtet"
  Plex legt die Empfängerliste unter dem Schlüssel `Dvr` ab, die Hülle
  kannte aber nur Metadata, Directory und Hub. Sie las eine leere Liste
  und zog daraus die falsche Schlussfolgerung — auf einem Server, auf
  dem ein Empfänger eingerichtet war. Dasselbe galt für die
  Anbieterliste unter `MediaProvider`, ohne die es keinen
  Programmführer gibt.

  Beim Umbau wäre beinahe die Fehlerdiagnose verloren gegangen: mit
  `try?` über alle Schlüssel verschwände auch ein echter Dekodierfehler
  und ergäbe stillschweigend eine leere Liste. Aus „ein Feld heißt jetzt
  anders" würde „die Mediathek ist leer". Jetzt wird erst geprüft, ob
  der Schlüssel da ist, und dann ohne try? dekodiert. Fünf Tests halten
  beides fest.

Qualität im Player wählbar
  Automatisch, Original, 1080p, 720p, 480p. Der Eingriff wird genau dann
  gebraucht, wenn es ruckelt — dafür will niemand den Film verlassen.
  Ein Wechsel baut den Strom neu auf (die Stufe steckt in der Adresse),
  merkt sich die Stelle und fährt sie wieder an. Der alte Transcoder
  wird vorher angehalten, sonst rechnet der Server zwei Ströme für einen
  Zuschauer.

AVPlayer-Anzeige entfernt
  Sie war eine Prüfhilfe aus Phase 1. Dieselbe Angabe steht weiterhin
  unter Einstellungen → Diagnose, wo sie hingehört.

137 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:44:20 +02:00

234 lines
11 KiB
Swift

import Foundation
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
///
/// Plex verwendet für alle vier denselben Datensatz und unterscheidet über
/// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf
/// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das
/// Aufteilen bei jedem Abruf.
/// Nur `Decodable`: geschrieben wird dieser Typ nirgends. Der Offline-Bestand
/// in Phase 3 bekommt ein eigenes SwiftData-Modell, weil er andere Felder
/// braucht — lokaler Dateipfad, Ladezeitpunkt — und andere nicht.
public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
public enum Kind: String, Sendable, Codable {
case movie, show, season, episode, clip, collection
}
/// Die Kennung, unter der der Server den Eintrag führt. Kommt als
/// Zeichenkette, obwohl es eine Zahl ist.
public let ratingKey: String
public let key: String
public let guid: String?
public let type: String
public let title: String
public let titleSort: String?
public let summary: String?
public let year: Int?
/// Laufzeit in Millisekunden.
public let duration: Int?
public let contentRating: String?
public let rating: Double?
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?
public let parentThumb: String?
public let grandparentThumb: String?
public let grandparentArt: String?
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
// Staffel, `grandparentTitle` der Serientitel.
public let index: Int?
public let parentIndex: Int?
public let parentTitle: String?
public let parentRatingKey: String?
public let grandparentTitle: String?
public let grandparentRatingKey: String?
// Fortschritt.
public let viewCount: Int?
/// Wie weit gesehen wurde, in Millisekunden.
public let viewOffset: Int?
public let lastViewedAt: Int?
public let addedAt: Int?
// Zählwerte bei Serien und Staffeln.
public let leafCount: Int?
public let viewedLeafCount: Int?
public let childCount: Int?
public let media: [PlexMedia]
// Schlagwörter. Plex liefert sie nur bei der Einzelabfrage eines Titels
// mit, nicht in Listen — deshalb sind sie dort leer.
public let genres: [PlexTag]
public let directors: [PlexTag]
public let writers: [PlexTag]
/// Die Besetzung, mit Porträt und Rollennamen.
public let roles: [PlexTag]
public let markers: [PlexMarker]
private enum CodingKeys: String, CodingKey {
case ratingKey, key, guid, type, title, titleSort, summary, year, duration
case contentRating, rating, audienceRating
case thumb, art, parentThumb, grandparentThumb, grandparentArt
case index, parentIndex, parentTitle, parentRatingKey
case grandparentTitle, grandparentRatingKey
case viewCount, viewOffset, lastViewedAt, addedAt
case leafCount, viewedLeafCount, childCount
case media = "Media"
case genres = "Genre"
case directors = "Director"
case writers = "Writer"
case roles = "Role"
case markers = "Marker"
}
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen
/// `Media`-Block verlangen würde. Plex liefert Einträge aber schon aus,
/// bevor es die Dateien analysiert hat — ohne diese Nachsicht wäre eine
/// frisch hinzugefügte Staffel als Ganzes unlesbar statt nur unvollständig.
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.ratingKey = try values.decode(String.self, forKey: .ratingKey)
self.key = try values.decodeIfPresent(String.self, forKey: .key) ?? "/library/metadata/\(ratingKey)"
self.guid = try values.decodeIfPresent(String.self, forKey: .guid)
self.type = try values.decode(String.self, forKey: .type)
self.title = try values.decode(String.self, forKey: .title)
self.titleSort = try values.decodeIfPresent(String.self, forKey: .titleSort)
self.summary = try values.decodeIfPresent(String.self, forKey: .summary)
self.year = try values.decodeIfPresent(Int.self, forKey: .year)
self.duration = try values.decodeIfPresent(Int.self, forKey: .duration)
self.contentRating = try values.decodeIfPresent(String.self, forKey: .contentRating)
self.rating = try values.decodeIfPresent(Double.self, forKey: .rating)
self.audienceRating = try values.decodeIfPresent(Double.self, forKey: .audienceRating)
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
self.art = try values.decodeIfPresent(String.self, forKey: .art)
self.parentThumb = try values.decodeIfPresent(String.self, forKey: .parentThumb)
self.grandparentThumb = try values.decodeIfPresent(String.self, forKey: .grandparentThumb)
self.grandparentArt = try values.decodeIfPresent(String.self, forKey: .grandparentArt)
self.index = try values.decodeIfPresent(Int.self, forKey: .index)
self.parentIndex = try values.decodeIfPresent(Int.self, forKey: .parentIndex)
self.parentTitle = try values.decodeIfPresent(String.self, forKey: .parentTitle)
self.parentRatingKey = try values.decodeIfPresent(String.self, forKey: .parentRatingKey)
self.grandparentTitle = try values.decodeIfPresent(String.self, forKey: .grandparentTitle)
self.grandparentRatingKey = try values.decodeIfPresent(String.self, forKey: .grandparentRatingKey)
self.viewCount = try values.decodeIfPresent(Int.self, forKey: .viewCount)
self.viewOffset = try values.decodeIfPresent(Int.self, forKey: .viewOffset)
self.lastViewedAt = try values.decodeIfPresent(Int.self, forKey: .lastViewedAt)
self.addedAt = try values.decodeIfPresent(Int.self, forKey: .addedAt)
self.leafCount = try values.decodeIfPresent(Int.self, forKey: .leafCount)
self.viewedLeafCount = try values.decodeIfPresent(Int.self, forKey: .viewedLeafCount)
self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount)
self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? []
self.genres = try values.decodeIfPresent([PlexTag].self, forKey: .genres) ?? []
self.directors = try values.decodeIfPresent([PlexTag].self, forKey: .directors) ?? []
self.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? []
self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? []
self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? []
}
/// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt
/// als Kennung für `ForEach`.
public var id: String { ratingKey }
public var kind: Kind? { Kind(rawValue: type) }
/// Wie weit der Titel gesehen ist, als Anteil von 0 bis 1.
///
/// `nil`, wenn noch gar nicht angefangen wurde — die Kachel zeigt dann
/// keinen Balken statt eines leeren.
public var watchProgress: Double? {
guard let viewOffset, let duration, duration > 0, viewOffset > 0 else { return nil }
return min(Double(viewOffset) / Double(duration), 1)
}
public var isWatched: Bool { (viewCount ?? 0) > 0 }
/// Die Fassung, die abgespielt werden soll, wenn der Nutzer nichts anderes
/// wählt: die mit der höchsten Bitrate. Plex sortiert nicht zuverlässig,
/// deshalb wird hier selbst ausgewählt.
public var preferredMedia: PlexMedia? {
media.max { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) }
}
/// Ob die Detailseite eine Versionsauswahl anbieten muss.
public var hasMultipleVersions: Bool { media.count > 1 }
}
/// Die Hülle, in die Plex jede Antwort packt.
///
/// Der Name des Inhaltsfelds wechselt je nach Endpunkt (`Metadata`,
/// `Directory`, `Hub`), die Hülle selbst bleibt gleich. Deshalb ist sie
/// generisch über den Inhalt.
public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decodable {
public let size: Int?
/// Wie viele Einträge es insgesamt gibt — wichtig fürs seitenweise
/// Nachladen, weil `size` nur die aktuelle Seite zählt.
public let totalSize: Int?
public let offset: Int?
public let title1: String?
public let title2: String?
public let content: [Content]
/// Unter welchen Namen Plex seine Sammlungen ablegt.
///
/// Es gibt keinen einheitlichen: Titel stehen unter `Metadata`,
/// Mediatheken unter `Directory`, die Reihen der Startseite unter `Hub`,
/// Fernsehempfänger unter `Dvr`, die Anbieterliste unter `MediaProvider`.
///
/// Genau daran war Live-TV gescheitert: die Hülle kannte `Dvr` nicht, las
/// eine leere Liste und meldete „kein Empfänger eingerichtet" — bei einem
/// Server, auf dem einer eingerichtet war.
///
/// Die Namen schließen sich gegenseitig aus, deshalb genügt es, sie der
/// Reihe nach zu probieren.
private static var contentKeys: [String] {
["Metadata", "Directory", "Hub", "Dvr", "MediaProvider", "Channel", "ChannelMapping", "Device"]
}
private struct DynamicKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(_ value: String) { stringValue = value }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { nil }
}
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
public init(from decoder: any Decoder) throws {
let outer = try decoder.container(keyedBy: OuterKeys.self)
let inner = try outer.nestedContainer(keyedBy: DynamicKey.self, forKey: .mediaContainer)
self.size = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("size"))
self.totalSize = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("totalSize"))
self.offset = try inner.decodeIfPresent(Int.self, forKey: DynamicKey("offset"))
self.title1 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title1"))
self.title2 = try inner.decodeIfPresent(String.self, forKey: DynamicKey("title2"))
// Erst nachsehen, ob der Schlüssel überhaupt da ist, und dann ohne
// `try?` dekodieren.
//
// Mit `try?` verschwände auch ein echter Dekodierfehler und ergäbe
// stillschweigend eine leere Liste — aus „ein Feld heißt jetzt anders"
// würde „die Mediathek ist leer". Genau diese Verwechslung hat bei
// Live-TV schon einmal auf die falsche Fährte geführt.
for name in Self.contentKeys where inner.contains(DynamicKey(name)) {
self.content = try inner.decode([Content].self, forKey: DynamicKey(name))
return
}
// Kein Schlüssel vorhanden: eine leere Mediathek liefert gar kein Feld.
// Das ist kein Fehler, sondern bedeutet null Einträge.
self.content = []
}
}