Die App meldete auf der Startseite „Dieser Titel ist auf dem Server nicht
mehr vorhanden" — über eine Folge, die sie selbst gerade anzeigte.
Gemessen im Quelltext: `item(id:)` probiert `/movies/{id}`, dann
`/series/{id}`, und wirft sonst `.notFound`. In „Weiterschauen" steht
eine Folge mit ihrer eigenen Kennung, und einen Endpunkt für eine
einzelne Folge gab es gar nicht. Jetzt gibt es `GET /api/v1/episodes/{id}`
— mit `SeriesId`, denn ohne die Serie fehlen der Folge Titel und Bild.
Im Simulator nachgestellt und nachgeprüft: die Seite steht.
**Zuletzt hinzugefügt.** Die App zeigte „Filme" und „Serien" — den Anfang
der Mediathek in der Reihenfolge der Datenbank, also immer denselben.
Jetzt je Mediathek eine Reihe „Neu in …", sortiert nach Zugang. Die
Kennungen beginnen nun mit `latest`, und daran hängen zwei Einstellungen,
die an einem Flaro-Server deshalb nichts taten: „Gesehenes in Neuzugängen
ausblenden" und „Aufmacher nur aus Neuzugängen".
**Geplante Titel.** `MediaItem` kennt jetzt `availableFrom`. Aufmacher und
Detailseite zeigen statt des Knopfes „Bald ist es soweit" mit dem Termin.
Die Kachel spielte trotzdem los — im Simulator kam der Spieler bis „Wird
vorbereitet …" —, also zählt ein geplanter Titel jetzt wie eine Sammlung:
nichts zu starten, der Tipp führt auf die Seite.
**Und das Bild im Aufmacher.** Bei einer angefangenen Folge stand dort ihr
Standbild statt des Bildes der Serie; wer der Serie eines ausgesucht
hatte, fand ein anderes vor. Die Kachel behält das Standbild — sie zeigt,
wo man steht —, der Aufmacher bekommt `SeriesBackdropImage`.
**Die Punkte im Karussell waren 26 × 3 Pixel.** Von drei Klicks auf
denselben Punkt traf nur der auf der Mittellinie; fünf Pixel daneben
geschah nichts. Der Knopf ist jetzt 27 Pixel hoch, sichtbar bleiben 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
12 KiB
Swift
328 lines
12 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, 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
|
||
|
||
/// 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,
|
||
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.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 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
|
||
}
|
||
}
|