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) } }