Flixy heißt ab hier Halo

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>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,72 @@
import Foundation
/// Etwas, das der Nutzer ausblenden und verschieben kann.
public protocol Arrangeable {
/// Eine Kennung, die über Abrufe hinweg gleich bleibt.
///
/// **Nicht** der angezeigte Titel: benennt der Server eine Reihe oder eine
/// Mediathek um, wäre sonst alles Gespeicherte hinfällig.
var arrangementID: String { get }
var arrangementTitle: String { get }
}
/// Was ausgeblendet ist und in welcher Reihenfolge der Rest steht.
///
/// War bis eben an die Startseiten-Reihen von Plex gebunden. Die Rechnung ist
/// dieselbe für jede Liste, die jemand selbst zusammenstellt — Reihen,
/// Seitenleisteneinträge, Sender. Sie ein zweites Mal zu schreiben hieße, sie
/// ein zweites Mal falsch zu machen.
public struct Arrangement: Sendable, Equatable {
public var hidden: Set<String>
public var order: [String]
public init(hidden: Set<String> = [], order: [String] = []) {
self.hidden = hidden
self.order = order
}
/// Wendet Auswahl und Reihenfolge an.
///
/// Unbekannte Einträge landen am Ende statt zu verschwinden. Sie zu
/// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein
/// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre.
public func apply<Element: Arrangeable>(to elements: [Element]) -> [Element] {
let visible = elements.filter { !hidden.contains($0.arrangementID) }
guard !order.isEmpty else { return visible }
let positions = Dictionary(
order.enumerated().map { ($1, $0) },
// Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa
// nach einem fehlgeschlagenen Schreibvorgang — soll nicht
// abstürzen. Die erste Position gewinnt.
uniquingKeysWith: { first, _ in first }
)
return visible.enumerated().sorted { left, right in
let leftPosition = positions[left.element.arrangementID] ?? Int.max
let rightPosition = positions[right.element.arrangementID] ?? Int.max
// Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge
// des Servers erhalten. Ohne diese Rückfalllinie wäre die
// Anordnung neuer Einträge zufällig.
if leftPosition == rightPosition { return left.offset < right.offset }
return leftPosition < rightPosition
}.map(\.element)
}
/// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge
/// zuerst, danach alles Übrige.
///
/// Gespeicherte Einträge, die es nicht mehr gibt, fallen heraus — sie
/// anzuzeigen hieße, eine Reihe anzubieten, die niemand mehr hat.
public func arrangedIdentifiers<Element: Arrangeable>(from elements: [Element]) -> [String] {
let available = elements.map(\.arrangementID)
let known = Set(available)
let stored = order.filter(known.contains)
return stored + available.filter { !stored.contains($0) }
}
}
extension MediaRowGroup: Arrangeable {
public var arrangementID: String { id }
public var arrangementTitle: String { title }
}
@@ -0,0 +1,24 @@
import Foundation
extension Duration {
/// Die Dauer als Gleitkommazahl in Sekunden.
///
/// Foundation und AVFoundation rechnen in `TimeInterval`; `Duration` ist
/// der genauere Typ, den der übrige Code verwendet.
///
/// Sie liegt in der neutralen Schicht und nicht beim Server — das ist
/// der Grund, warum Downloads und Wiedergabe ohne jeden Serverbezug
/// übersetzen. In einem Servermodul stand sie einmal, „weil es die
/// unterste Schicht ist"; das stimmte nur, solange es einen gab.
public var seconds: Double {
Double(components.seconds) + Double(components.attoseconds) / 1e18
}
/// Die Dauer in ganzen Millisekunden.
///
/// Die übliche Einheit, in der Server Fortschritt melden — Jellyfin
/// allerdings in Ticks zu 100 Nanosekunden, was sein Adapter umrechnet.
public var milliseconds: Int {
Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000)
}
}
@@ -0,0 +1,110 @@
import Foundation
/// Welche Folge dran ist, wenn jemand eine Serie oder Staffel startet.
///
/// **Warum als reiner Typ.** Liste hinein, eine Folge heraus. Kein Netz, keine
/// Einstellungen, keine Ansicht — dieselbe Bauform wie `HeroSelection` und
/// `AudioTrackChooser`, und aus demselben Grund: im App-Ziel gibt es kein
/// Unit-Test-Ziel, also muss alles, worin eine Entscheidung steckt, hierher.
/// „Er hat die falsche Folge gestartet" ist sonst ein Befund, den man nicht
/// nachstellen kann.
///
/// **Die Regel in einem Satz:** die älteste Folge, die noch nicht als gesehen
/// gilt; wurde sie angefangen, dort weiter; ist alles gesehen, von vorn.
public enum EpisodeSelection {
/// Ob die Liste eine ganze Serie ist oder genau eine Staffel.
///
/// Sie entscheidet nur zweierlei: ob Specials wegfallen und was „von vorn"
/// bedeutet. Alles Übrige ist gleich.
public enum Scope: Sendable, Equatable {
case series
case season
}
/// Was gespielt werden soll — und ob an der gemerkten Stelle.
public struct Choice: Sendable, Equatable {
public let episode: MediaItem
/// Ob an der gemerkten Stelle fortgesetzt wird.
public let resumes: Bool
public init(episode: MediaItem, resumes: Bool) {
self.episode = episode
self.resumes = resumes
}
}
/// Ab wo eine angefangene Folge **nicht** mehr fortgesetzt wird.
///
/// Ein Server, der einen Fortschritt vier Sekunden vor Schluss noch nicht
/// gelöscht hat, öffnete den Player sonst mitten im Abspann — und das
/// sieht aus wie „er hat die Folge übersprungen".
static let resumeCeiling = 0.95
/// Die Folge, mit der es weitergeht.
///
/// `nil`, wenn nach dem Aussortieren nichts übrig ist. Dann sagt der
/// Aufrufer das, statt lautlos nichts zu tun.
public static func nextUp(in items: [MediaItem], scope: Scope) -> Choice? {
let sortiert = ordered(items, scope: scope)
guard !sortiert.isEmpty else { return nil }
// **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.**
//
// Bei S1E1 gesehen, S1E2 unberührt und S1E5 halb gesehen kommt S1E2.
// Wer sich eine Folge für später aufgehoben hat, soll sie nicht
// dadurch verlieren, dass er irgendwo weiter vorn hineingeschaut hat.
guard let offen = sortiert.first(where: { !$0.isWatched }) else {
// Alles gesehen: von vorn. Bei einer Serie ist das die erste Folge
// der ersten Staffel, bei einer Staffel deren erste — derselbe
// Code, weil `scope` längst entschieden hat, was in der Liste
// liegt.
return Choice(episode: sortiert[0], resumes: false)
}
let angefangen = (offen.watchProgress ?? 0) > 0
&& (offen.watchProgress ?? 0) < resumeCeiling
return Choice(episode: offen, resumes: angefangen)
}
/// Die Sendereihenfolge — **nie** die des Servers.
///
/// Jellyfins `children(of:)` sortiert nach `SortName`, und das stellt
/// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so
/// lange gut, bis eine Staffel zehn Folgen hat.
static func ordered(_ items: [MediaItem], scope: Scope) -> [MediaItem] {
var übrig = items.filter { eintrag in
switch eintrag.kind {
// Behälter sind keine Folgen. `nil` bleibt dagegen: Jellyfin
// liefert in Listen nicht immer eine brauchbare Art, und
// Wegwerfen leerte die Liste.
case .season, .show, .collection: false
default: true
}
}
// **Specials nur bei der Serie aussortieren.** Staffel 0 vorzuziehen
// wäre falsch — sie steht vor Staffel 1 und ist trotzdem nicht der
// Anfang. Wer dagegen die Specials-Kachel angeklickt hat, meint die
// Specials.
if scope == .series {
übrig = übrig.filter { ($0.parentIndex ?? 1) != 0 }
}
// Fehlt eine Nummer, ist das keine Null, sondern „der Server hat
// nichts gesagt". Solche Einträge gehören ans Ende, nicht an den
// Anfang. Die Kennung zuletzt, damit dieselbe Eingabe dasselbe ergibt
// — sonst ist ein Fehler nicht wiederholbar.
übrig.sort { links, rechts in
let staffelL = links.parentIndex ?? .max
let staffelR = rechts.parentIndex ?? .max
if staffelL != staffelR { return staffelL < staffelR }
let folgeL = links.index ?? .max
let folgeR = rechts.index ?? .max
if folgeL != folgeR { return folgeL < folgeR }
return links.id < rechts.id
}
return übrig
}
}
@@ -0,0 +1,120 @@
import Foundation
/// Welche Titel oben gross gezeigt werden — und in welcher Reihenfolge.
///
/// **Warum es das als eigenen Typ gibt.** Die Auswahl soll sich zufällig
/// anfühlen und trotzdem stabil sein: dreimal am Abend dieselbe, am nächsten
/// Morgen eine andere. Das ist genau die Art Regel, die man nicht im
/// laufenden Betrieb beurteilen kann — „es wechselt zu oft" ist kein Befund,
/// den man debuggen könnte. Mit einer Liste hinein und einer heraus schon.
///
/// **Der Fehler, der es hierher gebracht hat.** Vorher wurde ein Zufallszahl-
/// erzeuger einmal je Zeitfenster aufgesetzt und dann Titel für Titel
/// abgefragt. Das Los hing damit an der **Position** in der Liste: kam ein
/// Titel dazu, verschwand einer, oder änderte sich der Fortschritt, bekamen
/// alle dahinter ein anderes Los — und der Aufmacher war nach jedem Neustart
/// ein anderer, obwohl das Zeitfenster dasselbe war.
///
/// Jetzt hängt das Los an der **Kennung** des Titels. Wer dazukommt, stört
/// niemanden; wer geht, hinterlässt eine Lücke und sonst nichts.
public enum HeroSelection {
/// Wie lange dieselbe Auswahl gilt.
public static let windowHours: Double = 6
/// Welche Reihen den Aufmacher speisen dürfen.
///
/// **Leer heisst alle.** Eine Einstellung, bei der nichts angekreuzt ist,
/// soll nicht in einen leeren Aufmacher führen — „ich habe nichts
/// gewählt" heisst „mir ist es gleich", nicht „nimm nichts".
///
/// **Ist etwas gewählt, kommt nur daher etwas.** Reihen, die sich keiner
/// Mediathek zuordnen lassen — Weiterschauen, Als Nächstes, Favoriten —
/// bleiben dann draussen.
///
/// Das war zuerst andersherum, und es war die falsche Entscheidung. Die
/// Überlegung „Angefangenes soll man nicht verlieren" klingt freundlich,
/// führt aber dazu, dass im Aufmacher weiter eine Serie steht, obwohl
/// gerade „nur Filme" eingestellt wurde — und niemand bringt das mit der
/// Einstellung in Verbindung. Eine Einstellung muss das tun, was sie
/// sagt; wer Weiterschauen oben haben will, wählt keine Mediathek aus.
public static func rows(
_ rows: [MediaRowGroup],
limitedTo libraries: Set<String>
) -> [MediaRowGroup] {
guard !libraries.isEmpty else { return rows }
let übrig = rows.filter { row in
guard let libraryID = row.libraryID else { return false }
return libraries.contains(libraryID)
}
// **Bleibt nichts übrig, gilt die Auswahl als wirkungslos.**
//
// Ein Aufmacher, der lautlos verschwindet, ist der schlechtere
// Fehler — man sucht ihn überall, nur nicht in einer Einstellung
// zwei Bildschirme weiter. Und es gibt Fälle, in denen das kein
// Versehen ist, sondern die Datenlage: eine gespeicherte Kennung
// nach einem Serverwechsel, oder ein Server, dessen Reihen gar
// keine Mediathek nennen.
return übrig.isEmpty ? rows : übrig
}
/// Ordnet die Titel, ohne die Reihenfolge fest vorzugeben.
///
/// **Ungleiche Lose.** Ohne Hintergrundbild ist der Aufmacher eine graue
/// Fläche mit Text, und ein Titel ohne eines gehört dort eigentlich nicht
/// hin. Ihn auszuschliessen wäre aber zu hart: bei einer Mediathek, in
/// der wenige Titel eines tragen, blieben immer dieselben drei übrig.
/// Also bekommt er ein schlechteres Los statt gar keines.
public static func ordered(
_ items: [MediaItem],
at time: Date = .now,
windowHours: Double = windowHours
) -> [MediaItem] {
let fenster = UInt64(max(0, time.timeIntervalSince1970 / (windowHours * 3_600)))
var bewertet: [(item: MediaItem, los: Double)] = []
bewertet.reserveCapacity(items.count)
for item in items {
bewertet.append((item, lot(for: item, window: fenster)))
}
// Bei gleichem Los die Kennung: zweimal dieselbe Eingabe soll zweimal
// dasselbe ergeben, sonst ist ein Fehler nicht wiederholbar.
bewertet.sort { links, rechts in
links.los == rechts.los ? links.item.id < rechts.item.id : links.los > rechts.los
}
return bewertet.map(\.item)
}
/// Das Los eines Titels in diesem Zeitfenster — zwischen 0 und 1.
static func lot(for item: MediaItem, window: UInt64) -> Double {
let hatBild = item.art != nil || item.seriesArt != nil
let gewicht = hatBild ? 1.0 : 0.2
return unitRandom(seed: stableHash(item.id) ^ window) * gewicht
}
/// Ein Streuwert, der über Programmläufe hinweg gleich bleibt.
///
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig;
/// damit wäre die Auswahl nach jedem Start eine andere — also genau der
/// Fehler, der hier behoben werden soll, nur an einer anderen Stelle.
static func stableHash(_ text: String) -> UInt64 {
// FNV-1a: vier Zeilen, keine Abhängigkeit, und für das Mischen von
// ein paar Kacheln mehr als gut genug.
var hash: UInt64 = 0xCBF2_9CE4_8422_2325
for byte in text.utf8 {
hash ^= UInt64(byte)
hash = hash &* 0x0000_0100_0000_01B3
}
return hash
}
/// SplitMix64, auf 0..<1 gebracht.
static func unitRandom(seed: UInt64) -> Double {
var z = seed &+ 0x9E37_79B9_7F4A_7C15
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
z = z ^ (z >> 31)
// Die oberen 53 Bit: genau die, die ein `Double` verlustfrei trägt.
return Double(z >> 11) / Double(1 << 53)
}
}
@@ -0,0 +1,174 @@
import Foundation
import OSLog
/// Was zuletzt in einer Mediathek stand — auf der Platte, für den nächsten
/// Aufruf.
///
/// **Warum.** Jeder Aufruf einer Mediathek war bisher ein Serverabruf, und
/// bis er zurückkam, stand da ein Ladekreis. Die Plakate selbst liegen längst
/// im Zwischenspeicher ([ArtworkLoader]); was fehlte, war die **Liste** —
/// Namen, Kennungen, Fortschritt, die Pfade zu genau diesen Plakaten. Ohne
/// sie ist der Bildspeicher wertlos: man weiß nicht, welche Bilder man zeigen
/// soll.
///
/// **Was er ausdrücklich nicht ist.** Keine zweite Wahrheit. Der Server bleibt
/// maßgeblich; das hier ist ein Abzug des letzten Standes, der sofort gezeigt
/// und stillschweigend ersetzt wird, sobald der Server geantwortet hat. Nur
/// wenn der Server **nicht** antwortet, bleibt der Abzug stehen — dann ist
/// eine Woche alte Liste besser als ein Fehlerbildschirm.
///
/// **Nur die erste Seite.** Nicht aus Sparsamkeit, sondern weil alles andere
/// nicht zusammenpasst: stünden 240 gespeicherte Titel da und der Server
/// antwortet mit den ersten 60, müsste entweder die Liste vor den Augen
/// schrumpfen oder der Versatz fürs Nachladen wäre falsch. Die erste Seite
/// deckt genau das ab, was beim Aufrufen zu sehen ist; alles Weitere lädt
/// nach wie vor nach, und die Plakate dafür liegen ohnehin schon da.
public actor LibraryCache {
public static let shared = LibraryCache()
/// Der gespeicherte Stand einer Mediathek.
public struct Snapshot: Codable, Sendable {
/// Zu welcher Abfrage er gehört.
///
/// Eine nach Erscheinungsjahr sortierte Liste ist für eine
/// alphabetische Ansicht wertlos, und eine gefilterte erst recht.
/// Passt der Schlüssel nicht, gilt der Stand als nicht vorhanden.
public let queryKey: String
public let items: [MediaItem]
public let totalCount: Int
public let savedAt: Date
public init(queryKey: String, items: [MediaItem], totalCount: Int, savedAt: Date = .now) {
self.queryKey = queryKey
self.items = items
self.totalCount = totalCount
self.savedAt = savedAt
}
}
private let root: URL
private let logger = Logger(subsystem: "app.halo", category: "LibraryCache")
/// Wie viele Titel höchstens gespeichert werden.
///
/// Eine Seite. Siehe oben, warum nicht mehr.
public static let pageLimit = 60
init(root: URL? = nil) {
self.root = root ?? FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appending(path: "LibraryCache", directoryHint: .isDirectory)
}
// MARK: - Lesen und Schreiben
public func snapshot(server: String, library: String, queryKey: String) -> Snapshot? {
guard let daten = try? Data(contentsOf: file(server: server, library: library)),
let stand = try? JSONDecoder().decode(Snapshot.self, from: daten)
else { return nil }
// Ein Stand zu einer anderen Sortierung ist kein Stand.
return stand.queryKey == queryKey ? stand : nil
}
public func store(_ snapshot: Snapshot, server: String, library: String) {
let gekürzt = Snapshot(
queryKey: snapshot.queryKey,
items: snapshot.items.prefix(Self.pageLimit).map(\.listEssentials),
totalCount: snapshot.totalCount,
savedAt: snapshot.savedAt
)
let ziel = file(server: server, library: library)
do {
try FileManager.default.createDirectory(
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
)
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
} catch {
// Ein misslungener Abzug ist kein Grund, irgendetwas abzubrechen —
// beim nächsten Mal wird eben geladen wie bisher.
logger.warning("Abzug nicht gespeichert: \(error.localizedDescription, privacy: .public)")
}
}
/// Die Liste der Mediatheken selbst.
///
/// Sie kommt beim Start, noch vor allem anderen, und ohne sie steht die
/// Seitenleiste leer.
public func libraries(server: String) -> [MediaLibrary]? {
guard let daten = try? Data(contentsOf: librariesFile(server: server)) else { return nil }
return try? JSONDecoder().decode([MediaLibrary].self, from: daten)
}
public func storeLibraries(_ libraries: [MediaLibrary], server: String) {
let ziel = librariesFile(server: server)
do {
try FileManager.default.createDirectory(
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
)
try JSONEncoder().encode(libraries).write(to: ziel, options: .atomic)
} catch {
logger.warning("Mediathekenliste nicht gespeichert: \(error.localizedDescription, privacy: .public)")
}
}
// MARK: - Aufräumen
/// Alles zu einem Server vergessen — beim Abmelden.
///
/// Was einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
/// verloren: Titel, Zusammenfassungen und Sehfortschritt sind persönlich.
public func clear(server: String) {
try? FileManager.default.removeItem(at: directory(server: server))
}
public func clearAll() {
try? FileManager.default.removeItem(at: root)
}
// MARK: - Wo was liegt
private func directory(server: String) -> URL {
root.appending(path: Self.safeName(server), directoryHint: .isDirectory)
}
private func file(server: String, library: String) -> URL {
directory(server: server).appending(path: "\(Self.safeName(library)).json")
}
private func librariesFile(server: String) -> URL {
directory(server: server).appending(path: "libraries.json")
}
/// Ein Name, der als Datei taugt.
///
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
/// die Serverkennung enthält einen Adressbestandteil mit Schrägstrichen.
/// Base64 ist umkehrbar und braucht keine Tabelle verbotener Zeichen — die
/// beiden Zeichen, die in Dateinamen stören, werden getauscht.
static func safeName(_ raw: String) -> String {
Data(raw.utf8).base64EncodedString()
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "=", with: "")
}
}
public extension MediaQuery {
/// Ein Schlüssel, der sich zwei Starts lang gleich liest.
///
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig;
/// ein damit benannter Abzug wäre nach jedem Neustart unauffindbar — der
/// Cache träfe nie, ohne dass irgendetwas kaputt aussähe.
var cacheKey: String {
[
sort.rawValue,
unwatchedOnly ? "u" : "",
favouritesOnly ? "f" : "",
genre ?? "",
year.map(String.init) ?? "",
contentRating ?? "",
resolution ?? "",
searchText ?? "",
].joined(separator: "|")
}
}
@@ -0,0 +1,374 @@
import Foundation
// MARK: - Was ein Sender ist
/// Der Griff, mit dem eine Implementierung ihren Sender wiederfindet.
///
/// Undurchsichtig mit Absicht. Bei Plex stecken darin die Schlüssel, die der
/// Suchlauf gefunden hat — Rasterschlüssel, Sendernummer am Empfänger, der
/// Empfänger selbst. Bei Jellyfin genügt die Kennung, und er bleibt leer.
///
/// Er wird **weitergereicht, nicht gelesen**. Wer einen Sender anzeigt, geht
/// sein Inhalt nichts an; wer ihn einschaltet, hat ihn selbst gefüllt.
///
/// Der Umweg ist nötig, weil beide Backends Werttypen sind und sich zwischen
/// zwei Aufrufen nichts merken können. Ohne ihn müsste das Einschalten die
/// Senderliste neu laden — bei Plex hieße das, den ganzen Suchlauf noch
/// einmal zu gehen.
public struct LiveTuningHandle: Sendable, Equatable, Hashable {
private let fields: [String: String]
public init(_ fields: [String: String] = [:]) {
self.fields = fields
}
public subscript(_ key: String) -> String? { fields[key] }
public var isEmpty: Bool { fields.isEmpty }
}
/// Ein Fernsehsender.
public struct LiveChannel: Sendable, Equatable, Identifiable {
public let id: String
/// Die Nummer, unter der er im Programm steht — „3" oder „11.1".
///
/// Optional, weil nicht jeder Server sie führt. Steht sie, gehört sie vor
/// den Namen: danach sucht man.
public let number: String?
public let title: String
public let artworkPath: String?
/// Was gerade läuft, sofern der Server es gleich mitliefert.
///
/// Jellyfin tut das auf Wunsch (`addCurrentProgram`), und dann spart es
/// einen zweiten Aufruf für die Senderliste.
public let currentProgram: LiveProgram?
public let tuning: LiveTuningHandle
public init(
id: String,
number: String? = nil,
title: String,
artworkPath: String? = nil,
currentProgram: LiveProgram? = nil,
tuning: LiveTuningHandle = LiveTuningHandle()
) {
self.id = id
self.number = number
self.title = title
self.artworkPath = artworkPath
self.currentProgram = currentProgram
self.tuning = tuning
}
}
// MARK: - Was eine Sendung ist
/// Eine Sendung im Programm.
public struct LiveProgram: Sendable, Equatable, Identifiable {
public let id: String
public let channelID: String
public let title: String
/// Der Folgentitel, wenn es einer ist.
public let subtitle: String?
public let summary: String?
public let startsAt: Date
public let endsAt: Date
public let artworkPath: String?
/// Ob sie zu einem laufenden Serien-Timer gehört.
///
/// Steht sie, ist die Sendung bereits zur Aufnahme vorgemerkt, und die
/// Oberfläche soll „Aufnehmen" nicht noch einmal anbieten.
public let seriesTimerID: String?
public let kind: Kind
/// Wovon eine Sendung handelt — soweit der Server es sagt.
///
/// Als Menge und nicht als einzelner Wert: eine Sportübertragung kann
/// zugleich eine Live-Sendung sein, und Jellyfin meldet beide Kennzeichen
/// nebeneinander.
public struct Kind: OptionSet, Sendable, Equatable, Hashable {
public let rawValue: Int
public init(rawValue: Int) { self.rawValue = rawValue }
public static let movie = Kind(rawValue: 1 << 0)
public static let series = Kind(rawValue: 1 << 1)
public static let news = Kind(rawValue: 1 << 2)
public static let sports = Kind(rawValue: 1 << 3)
public static let kids = Kind(rawValue: 1 << 4)
public static let live = Kind(rawValue: 1 << 5)
}
public init(
id: String,
channelID: String,
title: String,
subtitle: String? = nil,
summary: String? = nil,
startsAt: Date,
endsAt: Date,
artworkPath: String? = nil,
seriesTimerID: String? = nil,
kind: Kind = []
) {
self.id = id
self.channelID = channelID
self.title = title
self.subtitle = subtitle
self.summary = summary
self.startsAt = startsAt
self.endsAt = endsAt
self.artworkPath = artworkPath
self.seriesTimerID = seriesTimerID
self.kind = kind
}
/// Ob sie zu diesem Zeitpunkt läuft.
public func isRunning(at moment: Date = .now) -> Bool {
startsAt <= moment && endsAt > moment
}
/// Wie weit sie fortgeschritten ist, 0…1.
///
/// Gerechnet statt gemeldet: beide Server nennen Anfang und Ende, keiner
/// nennt den Fortschritt. Beim Umschalten ist er die nützlichste Angabe
/// überhaupt — er sagt, ob man noch einsteigt oder nur noch den Abspann
/// sieht.
public func progress(at moment: Date = .now) -> Double {
let total = endsAt.timeIntervalSince(startsAt)
guard total > 0 else { return 0 }
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
}
}
// MARK: - Ob es überhaupt etwas zu sehen gibt
/// Was beim Live-TV herauskommen kann.
public enum LiveTVAvailability: Sendable, Equatable {
/// Es gibt einen Empfänger und Sender.
///
/// Ohne Angabe, **welchen** Empfänger: das ist Sache der Implementierung.
/// Vorher stand hier die Liste der Plex-Empfänger, und damit trug die
/// neutrale Form einen fremden Typ durch die halbe App.
case available
/// Der Server läuft, hat aber keinen Empfänger eingerichtet.
///
/// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
/// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
/// einen Fehler zu zeigen.
case notConfigured
case failed(message: String)
}
/// Eine abgeklopfte Adresse mit ihrer Antwort.
///
/// Nur Plex füllt das: dort wird die Senderliste gesucht, weil ihre Adresse
/// sich zwischen Serverfassungen unterscheidet. Jellyfin hat einen festen
/// Endpunkt und liefert nichts — die Oberfläche zeigt den Block dann gar
/// nicht erst.
public struct LiveChannelProbe: Sendable, Equatable, Identifiable {
public let path: String
public let result: String
public var id: String { path }
public init(path: String, result: String) {
self.path = path
self.result = result
}
}
/// Die Senderliste samt dem, was ihr Zustandekommen erklärt.
public struct LiveChannelListing: Sendable, Equatable {
public let channels: [LiveChannel]
/// Woher sie kam. `nil`, wo es nichts zu erklären gibt.
public let source: String?
/// Jeder versuchte Weg. Leer, wo nicht gesucht werden musste.
public let probes: [LiveChannelProbe]
public init(channels: [LiveChannel], source: String? = nil, probes: [LiveChannelProbe] = []) {
self.channels = channels
self.source = source
self.probes = probes
}
}
// MARK: - Einschalten
/// Was beim Einschalten herauskam.
public struct LiveTuneResult: Sendable {
public let url: URL?
public let sessionID: String
/// Womit der Empfänger wieder freigegeben wird.
///
/// Ohne dieses Signal bleibt ein Tuner belegt, bis der Server von selbst
/// merkt, dass niemand zusieht — und vier Stück gibt es davon.
public let subscriptionKey: String?
/// Jeder versuchte Weg mit seiner Antwort.
public let trace: [LiveChannelProbe]
public init(
url: URL?,
sessionID: String,
subscriptionKey: String? = nil,
trace: [LiveChannelProbe] = []
) {
self.url = url
self.sessionID = sessionID
self.subscriptionKey = subscriptionKey
self.trace = trace
}
}
// MARK: - Aufnehmen
/// Eine fertige Aufnahme auf dem Server.
public struct LiveRecording: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let channelName: String?
public let summary: String?
public let startsAt: Date?
public let endsAt: Date?
public let artworkPath: String?
/// Ob gerade noch aufgenommen wird.
public let isInProgress: Bool
public init(
id: String,
title: String,
channelName: String? = nil,
summary: String? = nil,
startsAt: Date? = nil,
endsAt: Date? = nil,
artworkPath: String? = nil,
isInProgress: Bool = false
) {
self.id = id
self.title = title
self.channelName = channelName
self.summary = summary
self.startsAt = startsAt
self.endsAt = endsAt
self.artworkPath = artworkPath
self.isInProgress = isInProgress
}
}
/// Eine vorgemerkte Aufnahme.
public struct LiveTimer: Sendable, Equatable, Identifiable {
public let id: String
public let programID: String?
public let channelID: String?
public let channelName: String?
public let title: String
public let startsAt: Date?
public let endsAt: Date?
/// Ob sie zu einem Serien-Timer gehört.
public let seriesTimerID: String?
public init(
id: String,
programID: String? = nil,
channelID: String? = nil,
channelName: String? = nil,
title: String,
startsAt: Date? = nil,
endsAt: Date? = nil,
seriesTimerID: String? = nil
) {
self.id = id
self.programID = programID
self.channelID = channelID
self.channelName = channelName
self.title = title
self.startsAt = startsAt
self.endsAt = endsAt
self.seriesTimerID = seriesTimerID
}
}
/// Eine stehende Aufnahme für alles, was zu einer Serie gehört.
public struct LiveSeriesTimer: Sendable, Equatable, Identifiable {
public let id: String
public let name: String
public let channelID: String?
public let channelName: String?
/// Ob nur Erstausstrahlungen aufgenommen werden.
public let recordsNewOnly: Bool
/// Ob auf allen Sendern aufgenommen wird, nicht nur auf einem.
public let recordsAnyChannel: Bool
public init(
id: String,
name: String,
channelID: String? = nil,
channelName: String? = nil,
recordsNewOnly: Bool = false,
recordsAnyChannel: Bool = false
) {
self.id = id
self.name = name
self.channelID = channelID
self.channelName = channelName
self.recordsNewOnly = recordsNewOnly
self.recordsAnyChannel = recordsAnyChannel
}
}
// MARK: - Die Verträge
/// Was ein Server können muss, damit Live-TV funktioniert.
///
/// Bewusst **außerhalb** von `MediaBackend`: nicht jeder Server kann das, und
/// ein Vertrag, den die Hälfte der Erfüller nur mit Ausnahmen erfüllt, ist
/// keiner. Wer Live-TV will, fragt mit `as?` danach.
public protocol LiveTVBackend: Sendable {
/// Ob es einen Empfänger gibt.
///
/// Wirft nicht: „kein Empfänger eingerichtet" ist kein Fehler, sondern
/// eine Antwort, und die Oberfläche soll sie erklären können.
func liveTVAvailability() async -> LiveTVAvailability
/// Die Sender — und, wo gesucht werden musste, wie sie gefunden wurden.
func liveChannels() async -> LiveChannelListing
/// Das Programm für die genannten Sender in einem Zeitraum.
///
/// Nach Senderkennung gebündelt, weil das Raster genau so danach fragt.
func livePrograms(
channelIDs: [String],
from: Date,
to: Date
) async throws(MediaBackendError) -> [String: [LiveProgram]]
/// Schaltet ein.
///
/// Wirft nicht, sondern berichtet: scheitert es, ist die Spur der einzige
/// Weg zu sehen, woran. Genau daran hing beim Live-TV tagelang alles.
func tune(
channel: LiveChannel,
program: LiveProgram?,
maximumBitrate: Int?
) async -> LiveTuneResult
/// Gibt den Empfänger wieder frei.
func endLiveSession(subscriptionKey: String) async
}
/// Was ein Server darüber hinaus können muss, damit Aufnehmen funktioniert.
///
/// Getrennt vom Rest, weil Plex das eine kann und das andere nicht. Ein
/// `as? LiveTVRecordingBackend` sagt die Wahrheit; eine Methode, die bei Plex
/// nur wirft, verspricht etwas und bricht es dann.
public protocol LiveTVRecordingBackend: Sendable {
func recordings() async throws(MediaBackendError) -> [LiveRecording]
func deleteRecording(id: String) async throws(MediaBackendError)
func timers() async throws(MediaBackendError) -> [LiveTimer]
func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError)
func cancelTimer(id: String) async throws(MediaBackendError)
func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer]
func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError)
func cancelSeriesTimer(id: String) async throws(MediaBackendError)
}
@@ -0,0 +1,251 @@
import Foundation
/// Was ein Server können muss, damit die App mit ihm arbeiten kann.
///
/// Der Vertrag ist bewusst schmal. Alles, was nur ein Server kann — Plex' PIN-
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
/// der alles umfasst, wäre kein Nenner mehr.
public protocol MediaBackend: Sendable {
/// Wie der Server heißt, so wie er sich selbst nennt.
nonisolated var serverName: String { get }
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
/// wiedererkennen muss.
///
/// **Nicht der Name.** Zwei Server dürfen „Medien" heißen, und derselbe
/// Server darf umbenannt werden. Was hier steht, ist so gewählt, dass es
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
/// eine Kennung und geht nirgends an die Oberfläche.
nonisolated var identity: String { get }
/// Was dieser Server darüber hinaus beherrscht.
nonisolated var capabilities: Set<BackendCapability> { get }
// MARK: - Blättern
func libraries() async throws(MediaBackendError) -> [MediaLibrary]
func homeRows() async throws(MediaBackendError) -> [MediaRowGroup]
func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage
func item(id: String) async throws(MediaBackendError) -> MediaItem
/// In welcher Mediathek ein Titel liegt.
///
/// **Warum das eine Frage ist.** Ein Titel weiss es nicht von selbst: er
/// kennt seinen unmittelbaren Vorgesetzten — bei einer Folge die Staffel,
/// bei einem Film die Mediathek — und mehr nicht. Wer die Mediathek einer
/// Folge wissen will, muss die Kette hinaufgehen, und das ist eine
/// Serveranfrage.
///
/// Deshalb steht sie hier als eigene Frage und nicht als Feld an
/// `MediaItem`: sie wird selten gebraucht, kostet etwas, und ein Feld,
/// das meistens `nil` ist, verschweigt genau das.
///
/// `nil`, wenn der Server es nicht sagen kann — dann fällt die Ebene weg,
/// statt zu raten.
func libraryID(for item: MediaItem) async -> String?
func children(of id: String) async throws(MediaBackendError) -> [MediaItem]
func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup]
/// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek.
func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption]
/// Alle Folgen einer Serie, über alle Staffeln hinweg.
///
/// **Als eigene Frage, weil der allgemeine Weg zu teuer ist.** Staffeln
/// holen und dann jede Staffel einzeln kostet bei zwölf Staffeln
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
/// Die nächste Folge nach dieser, sofern es eine gibt.
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
// MARK: - Bilder
/// Ein Bild in der gewünschten Größe.
///
/// `nonisolated`, weil die Adresse ohne Netzzugriff entsteht und der
/// Systemplayer sie ohne Kopfzeilen abruft — der Token steckt deshalb in
/// der Adresse selbst.
nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL?
nonisolated func rawImageURL(path: String?) -> URL?
// MARK: - Herunterladen
/// Die Adresse, unter der eine Datei unverändert zu holen ist.
nonisolated func downloadURL(for file: MediaFile) -> URL?
/// Die Adresse für eine verkleinerte Fassung.
///
/// `nil`, wenn der Server das nicht anbietet — dann bleibt nur das
/// Original, und die Oberfläche soll die Wahl gar nicht erst zeigen.
nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL?
/// Die Adresse für dieselbe Datei in einer **anderen Schachtel**.
///
/// Gemeint ist Umpacken, nicht Umrechnen: dieselben Ströme, eine Hülle,
/// die der Systemplayer öffnen kann. Nötig für Dateien, die offline sonst
/// unspielbar wären — auf dem Gerät gibt es keinen Server mehr, der
/// nachträglich helfen könnte.
///
/// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original,
/// und der Titel ist offline nur so gut wie der Systemplayer.
///
/// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server
/// legt in die neue Hülle genau eine Tonspur und höchstens einen
/// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das
/// nicht nachholen.
nonisolated func remuxDownloadURL(
itemID: String,
versionIndex: Int,
audioStreamIndex: Int?,
subtitleStreamIndex: Int?
) -> URL?
// MARK: - Abspielen
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
///
/// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing
/// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum
/// er nicht mitspielt, und die Antwort wurde weggeworfen.
func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget
/// Beendet, was der Server für diese Wiedergabe angelegt hat.
///
/// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl
/// niemand mehr zusieht.
func endPlayback(sessionID: String) async
/// Meldet, wo man gerade steht.
///
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
/// wäre die schlechtere Hälfte des Tauschs.
func reportProgress(_ report: PlaybackReport) async
// MARK: - Zustand melden
func markWatched(id: String) async throws(MediaBackendError)
func markUnwatched(id: String) async throws(MediaBackendError)
/// Nimmt einen Titel aus „Weiterschauen", ohne ihn als gesehen zu melden.
///
/// **Der Fall, für den es sonst nichts gibt.** Nach einer halben Stunde
/// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll
/// nur nicht ewig weiter oben stehen. „Als gesehen markieren" wäre dafür
/// eine Lüge, „als ungesehen" tut nichts: der Fortschritt bleibt.
///
/// **Der ganze Titel, nicht nur seine Kennung.** Jellyfin kennt dafür
/// keinen eigenen Weg; dort wird das Benutzerdatenblatt geschrieben, und
/// darin stehen auch „gesehen" und „Favorit". Wer sie nicht mitschickt,
/// löscht sie — und der Aufrufer hat sie ohnehin zur Hand.
func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError)
func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError)
func rate(id: String, rating: Int) async throws(MediaBackendError)
func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError)
}
/// Was ein Server über das Nötigste hinaus kann.
///
/// Abgefragt statt vorausgesetzt: Plex kennt keine Favoriten, Jellyfin keine
/// Plex-Empfänger, und eine Oberfläche, die Knöpfe für Dinge zeigt, die der
/// Server nicht kann, ist schlimmer als eine, die sie weglässt.
public enum BackendCapability: String, Sendable, Hashable {
case liveTV
/// Aufnehmen und Vormerken — nicht dasselbe wie `liveTV`.
///
/// Plex kann Sender zeigen und einschalten, aber in dieser App nichts
/// aufnehmen; Jellyfin kann beides. Eine Fähigkeit für beides zusammen
/// hieße, entweder Plex Live-TV wegzunehmen oder einen Knopf zu zeigen,
/// der dort nichts tut.
case liveTVRecordings
case favourites
case ratings
case libraryScan
/// Die Verwaltung des Servers aus der App heraus.
case administration
case transcoding
}
/// Ein Ausschnitt aus einer Mediathek.
///
/// „Seite" ist hier eine technische Portion, **keine Seite in der Oberfläche**.
/// Die Mediathek ist eine einzige lange Liste, die nachlädt, sobald man sich
/// dem Ende nähert — Blättern mit „weiter zu Seite 2" gehört nicht in eine
/// Mediathek, in der man stöbert.
public struct MediaPage: Sendable, Equatable {
public let items: [MediaItem]
/// Ab welcher Stelle dieser Ausschnitt beginnt.
public let offset: Int
/// Wie viele es insgesamt gibt — Grundlage fürs Nachladen.
public let totalCount: Int
public init(items: [MediaItem], offset: Int = 0, totalCount: Int) {
self.items = items
self.offset = offset
self.totalCount = totalCount
}
public var hasMore: Bool { offset + items.count < totalCount }
public var nextOffset: Int { offset + items.count }
}
public extension MediaBackend {
/// Vorgabe: der Server kann es nicht.
///
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
/// gibt — und wer es anbietet, sagt es ausdrücklich.
///
/// **Mit Spurnummern, weil beim Umpacken eine Wahl ansteht.** Der Server
/// legt in die neue Hülle genau eine Tonspur und höchstens einen
/// Untertitel. Welche, entscheidet der Aufrufer — offline lässt sich das
/// nicht nachholen.
nonisolated func remuxDownloadURL(
itemID: String,
versionIndex: Int,
audioStreamIndex: Int?,
subtitleStreamIndex: Int?
) -> URL? { nil }
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
///
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
///
/// **Ohne Sortieren und ohne Filtern.** Beides ist Sache von
/// `EpisodeSelection`, und es zweimal zu tun hiesse, dass die beiden
/// irgendwann auseinanderlaufen.
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
let staffeln = try await children(of: id)
var folgen: [MediaItem] = []
for staffel in staffeln {
folgen += (try? await children(of: staffel.id)) ?? []
}
return folgen
}
/// Ohne eigene Antwort keine Mediathek.
///
/// Plex beantwortet das heute nicht, und eine falsche Antwort wäre
/// schlechter als keine: sie stellte die Sprache einer fremden Mediathek
/// über den allgemeinen Wunsch.
func libraryID(for item: MediaItem) async -> String? { nil }
}
/// Ein möglicher Wert für einen Filter.
public struct MediaFilterOption: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public init(id: String, title: String) {
self.id = id
self.title = title
}
}
@@ -0,0 +1,29 @@
import Foundation
/// Was beim Sprechen mit einem Server schiefgehen kann.
///
/// Sechs der sieben 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)
/// Der Server hat verstanden und abgelehnt — mit eigener Begründung.
///
/// Nicht `.server(status:)`: dort steht eine Zahl, hier ein Satz, den der
/// Server selbst formuliert hat. Beim Live-TV war genau dieser Satz die
/// einzige brauchbare Spur — und er ging verloren, solange es diesen Fall
/// nicht gab.
case refused(reason: String)
}
@@ -0,0 +1,308 @@
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
}
}
@@ -0,0 +1,97 @@
import Foundation
/// Eine Mediathek auf dem Server.
public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
public enum Kind: String, Sendable, Equatable, Codable {
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. Live-TV hat einen eigenen Bildschirm und gehört nicht
/// noch einmal in die Mediathekenliste.
///
/// **`other` zählt dazu.** Hier stand vorher `movie || show`, und alles
/// Übrige fiel wortlos heraus — auch eine Mediathek mit gemischtem
/// Inhalt, die Jellyfin ohne `CollectionType` meldet. Wer Filme und
/// Serien nicht getrennt hat, sah sie nirgends: nicht in der
/// Seitenleiste, nicht auf der Startseite, nirgends eine Meldung. Eine
/// unbekannte Art ist kein Grund, etwas zu verschweigen — sie ist nur
/// ein Grund, nichts über den Inhalt anzunehmen.
public var isSupported: Bool {
switch kind {
case .movie, .show, .other: true
case .music, .photo, .liveTV: false
}
}
}
/// 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?
/// Aus welcher Mediathek diese Reihe stammt.
///
/// **`nil` heisst: lässt sich nicht zuordnen**, nicht „keine". „Neu in
/// Filme" gehört zu einer Mediathek; „Weiterschauen" und „Favoriten"
/// gehen quer durch alle, und der Server sagt an keiner Stelle, aus
/// welcher ein einzelner angefangener Titel kommt.
///
/// Gebraucht wird es für die Auswahl des Aufmachers: wer „Musikvideos"
/// dort nicht sehen will, muss die Reihe benennen können, die sie
/// mitbringt.
public let libraryID: String?
public let items: [MediaItem]
public init(
id: String,
title: String,
moreKey: String? = nil,
libraryID: String? = nil,
items: [MediaItem] = []
) {
self.id = id
self.title = title
self.moreKey = moreKey
self.libraryID = libraryID
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")
}
}
@@ -0,0 +1,165 @@
import Foundation
/// Was abgespielt werden soll und wie.
///
/// Die Entscheidung *ob* umgerechnet wird, fällt vorher — im
/// `PlaybackDecider`, aus Profil und Leitung. Hier steht nur noch, was der
/// Server daraufhin bereitstellen soll. Das ist die Trennung, die es vorher
/// nicht gab: der Player hat Plex' Transcoder unmittelbar angesprochen und
/// konnte deshalb mit keinem anderen Server reden.
public struct PlaybackIntent: Sendable, Equatable {
public let itemID: String
/// Welche Fassung — ein Film kann mehrfach vorliegen.
public let versionIndex: Int
/// Die Datei innerhalb der Fassung. Plex adressiert Dateien, Jellyfin
/// Quellen; beide finden sich hierüber wieder.
public let file: MediaFile
/// `nil` heißt: unverändert übertragen.
public let transcode: TranscodeSettings?
public let startTime: Duration
/// Ob der Server im selben Netz steht. Plex rechnet dann anders.
public let isLocalConnection: Bool
/// Welche Tonspur gespielt werden soll.
///
/// **Ohne diese Angabe wählt der Server.** Bei einem umgerechneten Stream
/// gibt es danach nur noch *eine* Tonspur — was hier nicht gesagt wird,
/// lässt sich später nicht mehr umstellen, weil im Player schlicht keine
/// zweite ankommt. Genau daran scheiterte die Sprachwahl bisher: sie lief
/// auf einer Liste mit einem Eintrag.
public let audioStreamIndex: Int?
/// Welcher Untertitel eingebrannt oder mitgeliefert werden soll.
///
/// Dieselbe Lage wie beim Ton: rechnet der Server um, liefert er genau
/// den einen aus, für den er sich entschieden hat. Wer erst im Player
/// wählen will, hat dann nichts mehr zur Auswahl.
///
/// **`-1` heisst ausdrücklich „keine".** Nicht dasselbe wie `nil`: das
/// hiesse „such dir was aus", und der Server sucht sich dann durchaus
/// etwas aus.
public let subtitleStreamIndex: Int?
public init(
itemID: String,
versionIndex: Int,
file: MediaFile,
transcode: TranscodeSettings? = nil,
startTime: Duration = .zero,
isLocalConnection: Bool = false,
audioStreamIndex: Int? = nil,
subtitleStreamIndex: Int? = nil
) {
self.itemID = itemID
self.versionIndex = versionIndex
self.file = file
self.transcode = transcode
self.startTime = startTime
self.isLocalConnection = isLocalConnection
self.audioStreamIndex = audioStreamIndex
self.subtitleStreamIndex = subtitleStreamIndex
}
}
/// Wie weit heruntergerechnet werden soll.
public struct TranscodeSettings: Sendable, Equatable {
/// In Kilobit je Sekunde. `nil` heißt „so gut wie möglich".
public let maximumBitrate: Int?
/// Etwa „1280x720". `nil` lässt die Auflösung, wie sie ist.
public let resolution: String?
/// Ob der Server eine **Leiter** liefern soll statt einer festen Stufe.
///
/// Dann enthält die Playlist mehrere Varianten, und der Player wechselt
/// zwischen ihnen, ohne dass der Stream neu aufgebaut werden muss. Die
/// angegebene Bitrate bleibt dabei die **oberste** Sprosse; was darunter
/// liegt, erfindet der Server.
///
/// Nur bei `automatic` gesetzt. Wer im Sheet ausdrücklich „720p · 3 Mbps"
/// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger"
/// unter unverändertem Etikett.
public let prefersAdaptive: Bool
/// Nur umpacken, nicht neu rechnen.
///
/// Dann werden die Ströme kopiert statt kodiert, und heraus kommt eine
/// **fortlaufende** MP4 statt einer HLS-Abspielliste. Beides ist wichtig:
/// kopieren verliert nichts und kostet den Server fast nichts, und eine
/// fortlaufende Datei lässt sich beliebig weit vorausladen — eine
/// Abspielliste nicht.
public let prefersRemux: Bool
public init(
maximumBitrate: Int?,
resolution: String?,
prefersAdaptive: Bool = false,
prefersRemux: Bool = false
) {
self.maximumBitrate = maximumBitrate
self.resolution = resolution
self.prefersAdaptive = prefersAdaptive
self.prefersRemux = prefersRemux
}
}
/// Was der Server für diese Wiedergabe bereitgestellt hat.
public struct PlaybackTarget: Sendable, Equatable {
public let url: URL
/// Die Sitzung, die der Server dafür angelegt hat.
///
/// Zum Aufräumen: ohne dieses Kennzeichen rechnet der Transcoder weiter,
/// wenn längst niemand mehr zusieht.
public let sessionID: String?
/// Welche Fassung gespielt wird.
///
/// Jellyfin will sie bei jeder Fortschrittsmeldung zurückhaben, sonst
/// ordnet es die Meldung keiner Wiedergabe zu.
public let versionID: String?
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
self.url = url
self.sessionID = sessionID
self.versionID = versionID
}
}
/// In welchem Zustand sich die Wiedergabe befindet.
public enum PlaybackReportState: String, Sendable {
/// Der erste Bericht einer Wiedergabe.
///
/// Eigener Fall, weil Jellyfin dafür einen eigenen Endpunkt hat: ohne ihn
/// erscheint der Titel nirgends als „läuft gerade", und die Sitzung, unter
/// der die weiteren Meldungen laufen, ist dem Server unbekannt. Plex
/// unterscheidet das nicht und behandelt ihn wie „spielt".
case started
case playing
case paused
/// Beim Verlassen des Players. Erst dieser Wert nimmt den Titel auf dem
/// Server aus „wird gerade abgespielt" heraus.
case stopped
case buffering
}
/// Wo man gerade steht.
public struct PlaybackReport: Sendable, Equatable {
public let itemID: String
public let sessionID: String?
public let versionID: String?
public let state: PlaybackReportState
public let time: Duration
public let duration: Duration
public init(
itemID: String,
sessionID: String? = nil,
versionID: String? = nil,
state: PlaybackReportState,
time: Duration,
duration: Duration
) {
self.itemID = itemID
self.sessionID = sessionID
self.versionID = versionID
self.state = state
self.time = time
self.duration = duration
}
}
extension PlaybackReportState: Equatable {}
@@ -0,0 +1,44 @@
import Foundation
/// Was der `PlaybackDecider` über eine Datei wissen muss — und nicht mehr.
///
/// Vorher nahm er die Plex-Modelle entgegen und las daraus sechs Felder.
/// Das band die Entscheidung an einen bestimmten Server, obwohl an ihr nichts
/// serverspezifisch ist: ob iOS eine Datei öffnen kann, hängt an Container und
/// Codecs, nicht daran, wer sie ausliefert.
///
/// Wie ernst die Kopplung war, zeigt der Player selbst: für die
/// Offline-Wiedergabe baute er sich **erfundene** Plex-Werte zusammen, nur um
/// diese Signatur zu bedienen — für eine Datei, die längst auf dem Gerät lag
/// und mit Plex nichts mehr zu tun hatte.
public struct MediaProfile: Sendable, Equatable {
/// Der Behälter, etwa `mkv` oder `mp4`.
public let container: String?
public let videoCodec: String?
/// Der Codec der Tonspur, die tatsächlich klingen soll.
///
/// Nicht irgendeiner: eine Datei mit deutschem AAC und englischem DTS ist
/// für AVPlayer in Ordnung, solange Deutsch gewählt ist.
public let audioCodec: String?
/// Gesamtbitrate in Kilobit je Sekunde.
public let bitrate: Int?
/// Ob der Server die Datei überhaupt schon untersucht hat.
///
/// Ohne bekannte Spuren lässt sich nichts entscheiden — dann ist der
/// Ersatzplayer die sichere Wahl.
public let hasKnownStreams: Bool
public init(
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
bitrate: Int? = nil,
hasKnownStreams: Bool = true
) {
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.bitrate = bitrate
self.hasKnownStreams = hasKnownStreams
}
}
@@ -0,0 +1,61 @@
import Foundation
/// Wonach eine Mediathek durchsucht wird.
///
/// Gefiltert und sortiert wird **auf dem Server**. Alles hierher zu holen und
/// selbst zu sieben wäre bei einer Mediathek mit fünftausend Folgen
/// aussichtslos — und die Server können es ohnehin besser.
///
/// Die Sortierarten sind neutral benannt. Vorher standen hier Plex'
/// Drahtwerte (`"titleSort:asc"`), was bedeutete: entweder Jellyfin lernt
/// Plex' Schreibweise, oder jede Ansicht muss wissen, mit wem sie spricht.
public struct MediaQuery: Sendable, Equatable {
public enum Sort: String, Sendable, Equatable, CaseIterable {
case titleAscending, titleDescending
case recentlyAdded
case releaseDate
case rating
case lastPlayed
}
/// Wonach gefiltert werden kann.
public enum Field: String, Sendable, Equatable {
case genre, year, contentRating, resolution
}
public var sort: Sort
public var unwatchedOnly: Bool
public var favouritesOnly: Bool
public var genre: String?
public var year: Int?
public var contentRating: String?
public var resolution: String?
/// Freitextsuche innerhalb der Mediathek.
public var searchText: String?
public init(
sort: Sort = .titleAscending,
unwatchedOnly: Bool = false,
favouritesOnly: Bool = false,
genre: String? = nil,
year: Int? = nil,
contentRating: String? = nil,
resolution: String? = nil,
searchText: String? = nil
) {
self.sort = sort
self.unwatchedOnly = unwatchedOnly
self.favouritesOnly = favouritesOnly
self.genre = genre
self.year = year
self.contentRating = contentRating
self.resolution = resolution
self.searchText = searchText
}
/// Ob überhaupt eingeschränkt wird — steuert die Anzeige des Filterknopfs.
public var isFiltering: Bool {
unwatchedOnly || favouritesOnly || genre != nil || year != nil
|| contentRating != nil || resolution != nil
}
}
@@ -0,0 +1,222 @@
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, Codable {
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, height > 0 { return height }
return resolution?.nominalHeight
}
/// Die Stufe, in der diese Fassung vorliegt.
public var resolution: VideoResolution? {
VideoResolution(width: width, height: height, serverLabel: videoResolution)
}
/// Die Auflösung als Wort — „4K", „Full HD".
///
/// Nicht die Zeichenkette des Servers: Plex sagt „4k" und „1080",
/// Jellyfin gibt Zahlen. Gemeint ist dasselbe, und keines davon ist ein
/// Name, den jemand benutzt.
public var resolutionLabel: String? { resolution?.name }
/// Eine kurze Beschreibung für die Versionsauswahl, etwa
/// „4K · HEVC · 38 Mbit/s".
public var qualityLabel: String {
var pieces: [String] = []
if let label = resolutionLabel { pieces.append(label) }
if let videoCodec { pieces.append(videoCodec.uppercased()) }
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
return pieces.joined(separator: " · ")
}
/// 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, Codable {
public let id: String
/// Der Pfad, über den die Datei abgerufen wird.
public let key: String?
/// Zu welchem Titel diese Datei gehört.
///
/// Plex adressiert Dateien unmittelbar und braucht das nicht; Jellyfin
/// adressiert über den Titel und nennt die Datei nur als Beiwerk. Ohne
/// diese Angabe traf die Adresse dort den falschen Weg, sobald ein Titel
/// in mehreren Fassungen vorliegt — und nur dann, was den Fehler zu einem
/// gemacht hätte, den man erst spät findet.
public let itemID: 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,
itemID: String? = nil,
container: String? = nil,
size: Int64? = nil,
duration: Int? = nil,
file: String? = nil,
streams: [MediaStream] = []
) {
self.id = id
self.key = key
self.itemID = itemID
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, Codable {
public enum Kind: String, Sendable, Equatable, Codable { 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
/// Wie die Spur **in der Datei** heißt.
///
/// Das zweite Signal neben dem Sprachkürzel — und manchmal das einzige
/// richtige. Wo im Kürzel „eng" steht, kann hier „Deutsch" stehen; genau
/// so sehen die Dateien aus, bei denen die Sprachwahl bisher danebengriff.
public let title: String?
/// Die Spur, die der Server als Standard markiert.
public let isDefault: Bool
/// Für Hörgeschädigte. **Nicht** Audiodeskription — dafür gibt es bei
/// Jellyfin kein Kennzeichen, die lässt sich nur am Namen erkennen.
public let isHearingImpaired: Bool
/// Die Originaltonspur, sofern die Datei das vermerkt.
public let isOriginal: 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,
title: String? = nil,
isDefault: Bool = false,
isHearingImpaired: Bool = false,
isOriginal: 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
self.title = title
self.isDefault = isDefault
self.isHearingImpaired = isHearingImpaired
self.isOriginal = isOriginal
}
/// Die Nummer, unter der der Server diese Spur führt.
public var index: Int? { Int(id) }
/// 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: " · ")
}
}
@@ -0,0 +1,37 @@
import Foundation
/// Wann nachgeladen wird.
///
/// **Nicht „die letzten fünf".** So stand es bisher, und zwei Dinge daran
/// gehen schief. Erstens ist das auf einem Raster aus sechs Spalten nicht
/// einmal eine Zeile: wer dort ankommt, wartet trotzdem. Zweitens — und das
/// ist der eigentliche Fehler — hing es am **Erscheinen** der Kachel. Beim
/// ersten Zeichnen erscheinen alle sechzig gleichzeitig, auch die letzten
/// fünf; das Nachladen lief also sofort los statt beim Scrollen.
///
/// Gemessen wird deshalb in **Zeilen**, und der Auslöser ist auf dem
/// Fernseher der Fokus statt des Erscheinens. Auf einem breiten Raster ist
/// das nicht Feinschliff: dort sind sechs Kacheln eine Zeile, und mit der
/// alten Regel wäre schon die erste Bildschirmseite „fast am Ende".
public struct PagingWindow: Sendable, Equatable {
/// Wie viele Kacheln nebeneinander stehen.
public let columns: Int
/// Wie viele Zeilen Vorlauf gewünscht sind.
public let rowsAhead: Int
public init(columns: Int, rowsAhead: Int = 2) {
self.columns = columns
self.rowsAhead = rowsAhead
}
/// - Parameter focusedIndex: Wo der Fokus gerade steht.
/// - Parameter loaded: Wie viele Einträge schon dastehen.
/// - Parameter total: Wie viele der Server insgesamt meldet.
public func shouldLoad(focusedIndex: Int, loaded: Int, total: Int) -> Bool {
// `columns <= 0` ist keine sinnvolle Frage, aber eine mögliche: ein
// Raster kennt seine Spaltenzahl erst nach dem ersten Zeichnen. Null
// zurückzugeben ist richtiger, als durch null zu teilen.
guard columns > 0, loaded > 0, loaded < total else { return false }
return focusedIndex >= loaded - columns * rowsAhead
}
}
@@ -0,0 +1,104 @@
import Foundation
import Security
/// Legt Tokens im Schlüsselbund ab.
///
/// Nicht in `UserDefaults`: ein Server-Token gibt vollen Zugriff auf die
/// Mediathek und bleibt gültig, bis er am Server zurückgezogen wird. In den
/// Voreinstellungen läge er als Klartext in einer Datei, die jedes Backup
/// mitnimmt.
///
/// Der Kontoname trennt die Server: `plex.server` und `jellyfin.server`
/// liegen nebeneinander. Wer zwischen beiden wechselt, soll beim
/// Zurückwechseln nicht neu anmelden müssen.
public protocol TokenStoring: Sendable {
func store(_ token: String, for account: String) throws
func token(for account: String) -> String?
func remove(for account: String)
}
public struct KeychainTokenStore: TokenStoring {
private let service: String
public init(service: String) {
self.service = service
}
public enum Failure: Error, Equatable {
case keychain(status: OSStatus)
}
public func store(_ token: String, for account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: Data(token.utf8),
// Ohne Gerätesperre kein Zugriff, und nicht in Backups übertragbar.
// Ein Token, der auf einem anderen Gerät wieder auftaucht, wäre
// ein stiller Zweitzugang zur Mediathek.
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
let insert = query.merging(attributes) { current, _ in current }
let addStatus = SecItemAdd(insert as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw Failure.keychain(status: addStatus) }
default:
throw Failure.keychain(status: status)
}
}
public func token(for account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
public func remove(for account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
/// Ein Ablageort im Arbeitsspeicher — für Tests und SwiftUI-Vorschauen.
public final class InMemoryTokenStore: TokenStoring, @unchecked Sendable {
private let lock = NSLock()
private var tokens: [String: String] = [:]
public init() {}
public func store(_ token: String, for account: String) throws {
lock.withLock { tokens[account] = token }
}
public func token(for account: String) -> String? {
lock.withLock { tokens[account] }
}
public func remove(for account: String) {
_ = lock.withLock { tokens.removeValue(forKey: account) }
}
}
@@ -0,0 +1,102 @@
import Foundation
/// Was auf dem Regal über der App steht, wenn man sie im Menü anwählt.
///
/// **Warum ein eigener Abzug und nicht `LibraryCache`.** Das Regal wird von
/// einer **App-Erweiterung** gezeichnet, also von einem anderen Prozess mit
/// eigener Sandbox. Der kommt weder an den Schlüsselbund noch an den
/// Zwischenspeicher der App, und er soll auch gar nicht: er hat weniger als
/// eine Sekunde Zeit, bevor tvOS ihn abbricht. Ein Netzabruf, eine Anmeldung,
/// ein Blättern — alles ausgeschlossen.
///
/// Also legt die App hin, was das Regal braucht, und sonst nichts: Titel,
/// Bildadresse, Kennung. Fertig aufbereitet, eine Datei, ein `JSONDecoder`.
///
/// **Zur Bildadresse.** Sie trägt den Zugangsschlüssel des Servers, weil
/// Jellyfin ihn für Bilder verlangt und die Erweiterung ihn sonst nirgends
/// herbekäme. Die Datei liegt in der App-Gruppe — erreichbar für die App und
/// ihre Erweiterungen, für sonst niemanden. Der Schlüsselbund bleibt, wo er
/// ist; hierher kommt nur, was ohnehin in jeder Bildanfrage steht.
public struct TopShelfSnapshot: Codable, Sendable, Equatable {
public struct Entry: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let subtitle: String?
/// Das Plakat, in der Grösse, die das Regal zeigt.
public let imageURL: URL?
/// Wie weit gesehen — für den Balken auf dem Plakat.
public let progress: Double?
public init(
id: String, title: String, subtitle: String? = nil,
imageURL: URL?, progress: Double? = nil
) {
self.id = id
self.title = title
self.subtitle = subtitle
self.imageURL = imageURL
self.progress = progress
}
}
public struct Section: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let entries: [Entry]
public init(id: String, title: String, entries: [Entry]) {
self.id = id
self.title = title
self.entries = entries
}
}
public let sections: [Section]
public let savedAt: Date
public init(sections: [Section], savedAt: Date = .now) {
self.sections = sections
self.savedAt = savedAt
}
/// Wie viele Reihen und wie viele Einträge darin höchstens stehen.
///
/// **Nicht aus Sparsamkeit.** Das Regal zeigt eine knappe Bildschirmbreite;
/// alles darüber hinaus lädt die Erweiterung nur, um es nie zu zeigen —
/// und sie hat dafür weniger als eine Sekunde.
public static let sectionLimit = 3
public static let entryLimit = 12
}
/// Wo der Abzug liegt und wie er dorthin kommt.
///
/// **In der App-Gruppe, nicht im Caches-Ordner.** Jeder Prozess hat seinen
/// eigenen; die App-Gruppe ist der einzige Ort, den App und Erweiterung
/// gemeinsam sehen.
public enum TopShelfStore {
/// Gibt `nil` zurück, wenn die App-Gruppe fehlt — und das ist der Fall,
/// bis sie im Entwicklerkonto eingetragen ist. Die Erweiterung zeigt dann
/// nichts, statt abzustürzen.
public static func location(group: String) -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: group)?
.appending(path: "topshelf.json", directoryHint: .notDirectory)
}
public static func write(_ snapshot: TopShelfSnapshot, group: String) {
guard let ziel = location(group: group),
let daten = try? JSONEncoder().encode(snapshot)
else { return }
// `.atomic`: die Erweiterung kann jederzeit lesen, auch mitten im
// Schreiben. Eine halb geschriebene Datei ergäbe eine leere Regalreihe
// statt eines Fehlers — und niemand käme darauf, warum.
try? daten.write(to: ziel, options: .atomic)
}
public static func read(group: String) -> TopShelfSnapshot? {
guard let quelle = location(group: group),
let daten = try? Data(contentsOf: quelle)
else { return nil }
return try? JSONDecoder().decode(TopShelfSnapshot.self, from: daten)
}
}
@@ -0,0 +1,135 @@
import Foundation
/// Die Auflösung als Stufe, nicht als Zahl.
///
/// **Warum es das gibt.** Bisher stand auf der Detailseite schlicht `1080` —
/// die Zeichenkette, die Plex liefert, in Großbuchstaben. Weder eine Zahl mit
/// Bedeutung noch ein Name, den jemand benutzt.
///
/// **Warum die Breite zählt und nicht die Höhe.** Ein Kinofilm liegt in 2,39:1
/// vor und misst dann 1920 × 804. Nach der Höhe wäre das „720p"; nach der
/// Breite ist es, was es ist. Genau so sahen die Fassungen aus, bei denen die
/// alte Einstufung danebenlag — und zwar ausgerechnet bei den großen Filmen.
/// Die Höhe bleibt als Notnagel für Server, die keine Breite melden.
public enum VideoResolution: Sendable, Equatable, Comparable, CaseIterable {
case sd
case hd
case fullHD
case qhd
case uhd4K
case uhd8K
/// Wie der Name in der Oberfläche steht.
///
/// Nicht übersetzt: „Full HD" und „4K" heißen in jeder Sprache so, und
/// eine Übersetzungstabelle für vier feste Wörter wäre eine Fehlerquelle
/// ohne Nutzen.
public var name: String {
switch self {
case .sd: "SD"
case .hd: "HD"
case .fullHD: "Full HD"
case .qhd: "QHD"
case .uhd4K: "4K"
case .uhd8K: "8K"
}
}
/// Die technische Schreibweise — für Stufen, die man wählt, statt für
/// Material, das man beschreibt.
public var technicalName: String {
switch self {
case .sd: "480p"
case .hd: "720p"
case .fullHD: "1080p"
case .qhd: "1440p"
case .uhd4K: "2160p"
case .uhd8K: "4320p"
}
}
/// Die Bildhöhe, die diese Stufe üblicherweise hat.
///
/// Für Entscheidungen, die eine Zahl brauchen — etwa welche
/// Download-Stufen bei diesem Material überhaupt Sinn ergeben.
public var nominalHeight: Int {
switch self {
case .sd: 480
case .hd: 720
case .fullHD: 1_080
case .qhd: 1_440
case .uhd4K: 2_160
case .uhd8K: 4_320
}
}
/// Stuft eine Fassung ein.
///
/// In dieser Reihenfolge: Breite, dann Höhe, dann der Name des Servers.
/// Gibt `nil` zurück, wenn nichts davon etwas hergibt — dann ist eine
/// erfundene Stufe schlechter als gar keine Angabe.
public init?(width: Int?, height: Int?, serverLabel: String? = nil) {
if let width, width > 0 {
self = Self.fromWidth(width)
} else if let height, height > 0 {
self = Self.fromHeight(height)
} else if let stufe = Self.fromServerLabel(serverLabel) {
self = stufe
} else {
return nil
}
}
/// Der verlässliche Weg. Die Breite ist vom Bildformat unabhängig.
private static func fromWidth(_ width: Int) -> VideoResolution {
switch width {
case 7_000...: .uhd8K
case 3_000..<7_000: .uhd4K // 3840 und 4096
case 2_000..<3_000: .qhd // 2560
case 1_700..<2_000: .fullHD // 1920
case 1_100..<1_700: .hd // 1280
default: .sd
}
}
/// Der Notnagel.
///
/// Aus der Höhe allein ist ein breitwandiges 1080p (804 Zeilen) nicht von
/// einem vollformatigen 720p zu unterscheiden. Das ist keine Nachlässigkeit
/// in den Grenzen, sondern liegt an der Angabe: die Information fehlt
/// schlicht. Deshalb kommt die Breite zuerst.
private static func fromHeight(_ height: Int) -> VideoResolution {
switch height {
case 2_800...: .uhd8K
case 1_500..<2_800: .uhd4K
case 1_250..<1_500: .qhd
case 850..<1_250: .fullHD
case 650..<850: .hd
default: .sd
}
}
/// Was Plex hinschreibt: „4k", „1080", „sd".
private static func fromServerLabel(_ raw: String?) -> VideoResolution? {
guard let raw = raw?.lowercased().trimmingCharacters(in: .whitespaces),
!raw.isEmpty
else { return nil }
switch raw {
case "8k", "4320", "4320p": return .uhd8K
case "4k", "uhd", "2160", "2160p": return .uhd4K
case "qhd", "1440", "1440p": return .qhd
case "fhd", "1080", "1080p": return .fullHD
case "hd", "720", "720p": return .hd
case "sd", "576", "576p", "480", "480p", "360", "360p", "240", "240p": return .sd
default: break
}
// „1080i" und Ähnliches: die Ziffern vorne deuten, den Rest verwerfen.
let ziffern = raw.prefix { $0.isNumber }
guard let zahl = Int(ziffern), zahl > 0 else { return nil }
// Über tausend ist es eine Breite, darunter eine Höhe — andersherum
// gibt es keine sinnvolle Lesart.
return zahl >= 2_000 ? fromWidth(zahl) : fromHeight(zahl)
}
}