Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
11 KiB
Swift
309 lines
11 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
|
||
|
||
// 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 {
|
||
/// 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 }
|
||
|
||
/// 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
|
||
}
|
||
}
|