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.flixy", 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: "|") } }