Halo heisst ab hier Flaro — die Marke stand im Weg

Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 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.flaro", 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)
}
}