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:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user