diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackendError.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackendError.swift new file mode 100644 index 0000000..21e6d50 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackendError.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Was beim Sprechen mit einem Server schiefgehen kann. +/// +/// Dieselben sechs Fälle galten schon für Plex — sie sind nicht serverbezogen, +/// sondern beschreiben die Lage, in der man steckt. Dass es sie jetzt nur +/// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in +/// lesbaren Text stand an zwei Stellen, und die beiden waren bereits +/// auseinandergelaufen. +public enum MediaBackendError: Error, Equatable, Sendable { + /// Der Token gilt nicht mehr. Führt zur Abmeldung, nicht zu einer Meldung. + case unauthorized + case notFound + /// Keine der bekannten Adressen antwortet. + case noReachableConnection + /// Der Server hat geantwortet, aber mit einem Fehler. + case server(status: Int) + /// Die Anfrage kam gar nicht an. + case transport(description: String) + /// Der Server hat geantwortet, aber anders als erwartet. + case decoding(description: String) +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift b/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift new file mode 100644 index 0000000..a766bae --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift @@ -0,0 +1,254 @@ +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 + } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift b/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift new file mode 100644 index 0000000..c7d5c28 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Eine Mediathek auf dem Server. +public struct MediaLibrary: Sendable, Equatable, Identifiable { + public enum Kind: String, Sendable, Equatable { + case movie, show, music, photo, liveTV, other + } + + public let id: String + public let title: String + public let kind: Kind + public let thumb: String? + public let art: String? + + public init( + id: String, + title: String, + kind: Kind, + thumb: String? = nil, + art: String? = nil + ) { + self.id = id + self.title = title + self.kind = kind + self.thumb = thumb + self.art = art + } + + /// Ob die App mit dieser Mediathek etwas anfangen kann. + /// + /// Musik und Fotos sind bewusst außen vor: sie bräuchten eigene Ansichten + /// und eigene Wiedergabe, und eine halbherzige Musikansicht ist schlechter + /// als gar keine. + public var isSupported: Bool { kind == .movie || kind == .show } +} + +/// Eine Reihe auf der Startseite oder in den Suchergebnissen. +public struct MediaRowGroup: Sendable, Equatable, Identifiable { + /// Eine stabile Kennung für diese Reihe. + /// + /// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben + /// hat — und muss deshalb über Abrufe hinweg gleich bleiben, auch wenn + /// sich der angezeigte Titel ändert. + public let id: String + public let title: String + /// Der Pfad, unter dem es „alles davon" gäbe. + public let moreKey: String? + public let items: [MediaItem] + + public init(id: String, title: String, moreKey: String? = nil, items: [MediaItem] = []) { + self.id = id + self.title = title + self.moreKey = moreKey + self.items = items + } + + /// Ob diese Reihe das Weiterschauen ist. + /// + /// Sie wird anders dargestellt: dort zeigt das Szenenbild, wo man + /// aufgehört hat, während man sonst nach Plakaten sucht. + public var isContinueWatching: Bool { + let lowered = id.lowercased() + return lowered.contains("continue") || lowered.contains("resume") || lowered.contains("nextup") + } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift new file mode 100644 index 0000000..01028b1 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift @@ -0,0 +1,173 @@ +import Foundation + +/// Eine Fassung eines Titels — eine bestimmte Datei in einer bestimmten Güte. +/// +/// Ein Film kann mehrfach vorliegen: als 4K-Remux und als kleine Fassung fürs +/// Handy. Welche gespielt wird, entscheidet der Nutzer oder die Voreinstellung. +public struct MediaVersion: Sendable, Equatable, Identifiable { + public let id: String + public let container: String? + public let videoCodec: String? + public let audioCodec: String? + /// Die Auflösung, wie der Server sie nennt — „1080", „4k". + public let videoResolution: String? + /// Gesamtbitrate in Kilobit je Sekunde. + public let bitrate: Int? + public let width: Int? + public let height: Int? + public let audioChannels: Int? + public let duration: Int? + public let files: [MediaFile] + + public init( + id: String, + container: String? = nil, + videoCodec: String? = nil, + audioCodec: String? = nil, + videoResolution: String? = nil, + bitrate: Int? = nil, + width: Int? = nil, + height: Int? = nil, + audioChannels: Int? = nil, + duration: Int? = nil, + files: [MediaFile] = [] + ) { + self.id = id + self.container = container + self.videoCodec = videoCodec + self.audioCodec = audioCodec + self.videoResolution = videoResolution + self.bitrate = bitrate + self.width = width + self.height = height + self.audioChannels = audioChannels + self.duration = duration + self.files = files + } + + /// Die Höhe in Bildzeilen. + /// + /// Nicht immer gemeldet: manche Server nennen nur „4k" und keine Zahl. + /// Danach richtet sich, welche Qualitätsstufen überhaupt Sinn ergeben — + /// eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu. + public var pixelHeight: Int? { + if let height { return height } + switch videoResolution?.lowercased() { + case "4k": return 2_160 + case "1080": return 1_080 + case "720": return 720 + case "480": return 480 + case "sd": return 480 + default: return nil + } + } + + /// Was die Wiedergabe-Entscheidung über diese Fassung wissen muss. + public func profile(file: MediaFile?) -> MediaProfile { + MediaProfile( + container: file?.container ?? container, + videoCodec: videoCodec ?? file?.videoStream?.codec, + // Die **gewählte** Tonspur, nicht irgendeine: eine Datei mit + // deutschem AAC und englischem DTS ist für AVPlayer in Ordnung, + // solange Deutsch klingt. + audioCodec: file?.selectedAudioStream?.codec ?? audioCodec, + bitrate: bitrate, + hasKnownStreams: !(file?.streams.isEmpty ?? true) + ) + } +} + +/// Eine Datei auf dem Server. +public struct MediaFile: Sendable, Equatable, Identifiable { + public let id: String + /// Der Pfad, über den die Datei abgerufen wird. + public let key: String? + public let container: String? + /// Größe in Bytes. + /// + /// `Int64`, nicht `Int`: ein Remux überschreitet vier Gigabyte mühelos, + /// und auf einer 32-Bit-Plattform wäre die Zahl dann falsch. + public let size: Int64? + public let duration: Int? + /// Der Pfad im Dateisystem des Servers — nur zur Anzeige. + public let file: String? + public let streams: [MediaStream] + + public init( + id: String, + key: String? = nil, + container: String? = nil, + size: Int64? = nil, + duration: Int? = nil, + file: String? = nil, + streams: [MediaStream] = [] + ) { + self.id = id + self.key = key + self.container = container + self.size = size + self.duration = duration + self.file = file + self.streams = streams + } + + public var videoStream: MediaStream? { streams.first { $0.kind == .video } } + public var audioStreams: [MediaStream] { streams.filter { $0.kind == .audio } } + public var subtitleStreams: [MediaStream] { streams.filter { $0.kind == .subtitle } } + + /// Die Tonspur, die klingen soll. + /// + /// Die ausdrücklich gewählte, sonst die erste. Daran hängt die + /// Entscheidung, ob AVPlayer die Datei öffnen kann. + public var selectedAudioStream: MediaStream? { + audioStreams.first { $0.isSelected } ?? audioStreams.first + } +} + +/// Eine Spur in einer Datei. +public struct MediaStream: Sendable, Equatable, Identifiable { + public enum Kind: Sendable, Equatable { case video, audio, subtitle } + + public let id: String + public let kind: Kind + public let codec: String? + public let language: String? + public let languageCode: String? + /// Wie der Server die Spur nennt — etwa „Deutsch (AC3 5.1)". + public let displayTitle: String? + public let channels: Int? + public let isSelected: Bool + /// Untertitel, die auch bei abgeschalteten Untertiteln erscheinen sollen. + public let isForced: Bool + + public init( + id: String, + kind: Kind, + codec: String? = nil, + language: String? = nil, + languageCode: String? = nil, + displayTitle: String? = nil, + channels: Int? = nil, + isSelected: Bool = false, + isForced: Bool = false + ) { + self.id = id + self.kind = kind + self.codec = codec + self.language = language + self.languageCode = languageCode + self.displayTitle = displayTitle + self.channels = channels + self.isSelected = isSelected + self.isForced = isForced + } + + /// Ein brauchbarer Name, auch wenn der Server keinen liefert. + /// + /// „Spur 3" hilft niemandem bei der Wahl; Sprache und Codec schon. + public var name: String { + if let displayTitle, !displayTitle.isEmpty { return displayTitle } + let parts = [language, codec?.uppercased()].compactMap(\.self) + return parts.isEmpty ? "—" : parts.joined(separator: " · ") + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift new file mode 100644 index 0000000..f25c87c --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem+Media.swift @@ -0,0 +1,186 @@ +import Foundation +import MediaKit + +/// Die Übersetzung der Plex-Drahtmodelle in die neutrale Form. +/// +/// Eine Datei, eine Richtung. Danach ist unerheblich, von welchem Server ein +/// Titel stammt — und genau das war das Ziel: die Oberfläche soll nicht an +/// dreißig Stellen wissen müssen, wie Plex seine Felder nennt. +/// +/// Die Drahtmodelle bleiben, wo sie sind. Sie tragen Eigenheiten, die nur +/// Plex hat (dynamische Schlüssel in der Hülle, `grandparent` für die Serie, +/// Sekunden seit 1970 als Zahl), und die gehören nicht nach oben. +extension PlexItem { + public var asMediaItem: MediaItem { + MediaItem( + id: ratingKey, + path: key, + kind: kind.map(Self.kind) ?? nil, + title: title, + sortTitle: titleSort, + summary: summary, + year: year, + duration: duration, + contentRating: contentRating, + rating: rating, + audienceRating: audienceRating, + thumb: thumb, + art: art, + parentThumb: parentThumb, + seriesThumb: grandparentThumb, + seriesArt: grandparentArt, + index: index, + parentIndex: parentIndex, + parentTitle: parentTitle, + parentID: parentRatingKey, + seriesTitle: grandparentTitle, + seriesID: grandparentRatingKey, + playCount: viewCount, + resumeMilliseconds: viewOffset, + lastPlayedAt: lastViewedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + addedAt: addedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + // Plex kennt keine Favoriten. Ehrlicher als eine erfundene + // Markierung, die sich nirgends setzen ließe. + isFavourite: false, + episodeCount: leafCount, + playedEpisodeCount: viewedLeafCount, + childCount: childCount, + versions: media.map(\.asMediaVersion), + genres: genres.map(\.tag), + directors: directors.map(\.tag), + writers: writers.map(\.tag), + cast: roles.map(\.asMediaPerson), + markers: markers.map(\.asMediaMarker), + artwork: images.map(\.asMediaArtwork) + ) + } + + private static func kind(_ plex: PlexItem.Kind) -> MediaItem.Kind { + switch plex { + case .movie: .movie + case .show: .show + case .season: .season + case .episode: .episode + case .clip: .clip + case .collection: .collection + } + } +} + +extension PlexMedia { + public var asMediaVersion: MediaVersion { + MediaVersion( + id: String(id), + container: container, + videoCodec: videoCodec, + audioCodec: audioCodec, + videoResolution: videoResolution, + bitrate: bitrate, + width: width, + height: height, + audioChannels: audioChannels, + duration: duration, + files: parts.map(\.asMediaFile) + ) + } +} + +extension PlexPart { + public var asMediaFile: MediaFile { + MediaFile( + id: String(id), + key: key, + container: container, + size: size, + duration: duration, + file: file, + streams: streams.compactMap(\.asMediaStream) + ) + } +} + +extension PlexStream { + /// `nil`, wenn Plex eine Spurart meldet, mit der die App nichts anfängt. + public var asMediaStream: MediaStream? { + let mapped: MediaStream.Kind + switch kind { + case .video: mapped = .video + case .audio: mapped = .audio + case .subtitle: mapped = .subtitle + case nil: return nil + } + + return MediaStream( + id: String(id), + kind: mapped, + codec: codec, + language: language, + languageCode: languageCode, + displayTitle: displayTitle ?? title, + channels: channels, + isSelected: isSelected, + isForced: isForced + ) + } +} + +extension PlexTag { + public var asMediaPerson: MediaPerson { + MediaPerson(id: id, name: tag, role: role, thumb: thumb) + } +} + +extension PlexMarker { + public var asMediaMarker: MediaMarker { + MediaMarker( + kind: MediaMarker.Kind(rawValue: type) ?? .unknown, + startMilliseconds: startTimeOffset, + endMilliseconds: endTimeOffset + ) + } +} + +extension PlexImage { + public var asMediaArtwork: MediaArtwork { + MediaArtwork( + kind: MediaArtwork.Kind(rawValue: type) ?? .unknown, + path: url + ) + } +} + +extension PlexLibrary { + public var asMediaLibrary: MediaLibrary { + MediaLibrary( + id: key, + title: title, + kind: mediaKind, + thumb: thumb, + art: art + ) + } +} + +extension PlexLibrary { + private var mediaKind: MediaLibrary.Kind { + switch kind { + case .movie: .movie + case .show: .show + case .artist: .music + case .photo: .photo + case .livetv: .liveTV + case nil: .other + } + } +} + +extension PlexHub { + public var asMediaRowGroup: MediaRowGroup { + MediaRowGroup( + id: hubIdentifier, + title: title, + moreKey: more == true ? key : nil, + items: items.map(\.asMediaItem) + ) + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/MediaTranslationTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/MediaTranslationTests.swift new file mode 100644 index 0000000..2882d23 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/MediaTranslationTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +import MediaKit +@testable import PlexKit + +/// Die Übersetzung der Plex-Modelle in die neutrale Form. +/// +/// Gegen eine echte Server-Antwort, nicht gegen erfundene Werte: die +/// Eigenheiten, an denen eine Übersetzung scheitert, stehen genau dort — +/// „grandparent" für die Serie, Sekunden seit 1970 als Zahl, Fassungen und +/// Dateien ineinander verschachtelt. +@Suite("Übersetzung nach MediaKit") +struct MediaTranslationTests { + + private func item(_ fixture: String) throws -> PlexItem { + let url = try #require(Bundle.module.url(forResource: "Fixtures/\(fixture)", withExtension: "json")) + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: try Data(contentsOf: url) + ) + return try #require(container.content.first) + } + + @Test("Aus dem Großelternteil wird die Serie") + func translatesTheGrandparent() throws { + let episode = try item("episodes-unanalyzed").asMediaItem + + // Der Name war von Anfang an irreführend: gemeint ist die Serie, und + // in jeder Ansicht musste man ihn im Kopf übersetzen. + #expect(episode.kind == .episode) + #expect(episode.title == "Die erste Folge") + #expect(episode.seriesTitle == "Eine Serie") + #expect(episode.index == 1) + #expect(episode.parentIndex == 1) + } + + @Test("Fassungen und Dateien kommen vollständig mit") + func translatesVersions() throws { + let movie = try item("movie-with-two-versions").asMediaItem + + #expect(movie.versions.count == 2) + #expect(movie.hasMultipleVersions) + + let first = try #require(movie.versions.first) + #expect(first.files.isEmpty == false) + #expect(first.container != nil) + } + + @Test("Die Wiedergabe-Entscheidung bekommt dasselbe Profil wie vorher") + func buildsTheSameProfile() throws { + // Beide Wege müssen dasselbe sagen — sonst spielte ein Titel nach dem + // Umbau plötzlich über einen anderen Player. + let plex = try item("movie-with-two-versions") + let media = try #require(plex.media.first) + let part = try #require(media.parts.first) + + let viaPlex = media.profile(part: part) + let viaMedia = try #require(plex.asMediaItem.versions.first) + .profile(file: plex.asMediaItem.versions.first?.files.first) + + #expect(viaPlex == viaMedia) + } + + @Test("Ohne Fortschritt gibt es keinen Balken") + func leavesUnstartedItemsAlone() throws { + let movie = try item("movie-with-two-versions").asMediaItem + + // `watchProgress` ist bewusst `nil` statt 0: ein Balken bei null + // Prozent sieht aus wie ein Fehler, nicht wie „noch nicht begonnen". + if movie.resumeMilliseconds == nil || movie.resumeMilliseconds == 0 { + #expect(movie.watchProgress == nil) + } + } + + @Test("Plex kennt keine Favoriten und behauptet auch keine") + func doesNotInventFavourites() throws { + #expect(try item("movie-with-two-versions").asMediaItem.isFavourite == false) + } +}