Files
ScarriffleandClaude Opus 5 8f0568cb6c Ohne Server stand man vor der Tür, mit dem Film in der Tasche
Fiel der Server aus, endete der Start bei einem Fehlerbildschirm mit
zwei Knöpfen: „Erneut versuchen" und „Abmelden". Beide nützen im
Flugzeug nichts — und dahinter lagen die Dateien, die man genau für
diesen Fall heruntergeladen hatte.

Jetzt geht die App trotzdem auf: Token und Konto liegen auf dem Gerät,
die Mediathekenliste auch, und die Startseite kommt neuerdings aus
demselben Speicher. Oben die Downloads, darunter der Stand vom letzten
Mal, ausgegraut, mit einem Satz, der sagt warum. Die Detailseite nimmt
den Titel vom Gerät, wenn der Server schweigt, und der Abspielknopf
bleibt an, weil die Datei ja da ist.

Und was man offline geschaut hat, zählt: Fortschritt, den der Server
nicht annimmt, wird zurückgelegt und nachgereicht, sobald er wieder
antwortet. Dafür sagt `reportProgress` jetzt, ob die Meldung ankam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:41:22 +02:00

214 lines
8.7 KiB
Swift

import Foundation
import OSLog
/// Was zuletzt in einer Mediathek stand — auf der Platte, für den nächsten
/// Aufruf.
///
/// **Warum.** Jeder Aufruf einer Mediathek war bisher ein Serverabruf, und
/// bis er zurückkam, stand da ein Ladekreis. Die Plakate selbst liegen längst
/// im Zwischenspeicher ([ArtworkLoader]); was fehlte, war die **Liste** —
/// Namen, Kennungen, Fortschritt, die Pfade zu genau diesen Plakaten. Ohne
/// sie ist der Bildspeicher wertlos: man weiß nicht, welche Bilder man zeigen
/// soll.
///
/// **Was er ausdrücklich nicht ist.** Keine zweite Wahrheit. Der Server bleibt
/// maßgeblich; das hier ist ein Abzug des letzten Standes, der sofort gezeigt
/// und stillschweigend ersetzt wird, sobald der Server geantwortet hat. Nur
/// wenn der Server **nicht** antwortet, bleibt der Abzug stehen — dann ist
/// eine Woche alte Liste besser als ein Fehlerbildschirm.
///
/// **Nur die erste Seite.** Nicht aus Sparsamkeit, sondern weil alles andere
/// nicht zusammenpasst: stünden 240 gespeicherte Titel da und der Server
/// antwortet mit den ersten 60, müsste entweder die Liste vor den Augen
/// schrumpfen oder der Versatz fürs Nachladen wäre falsch. Die erste Seite
/// deckt genau das ab, was beim Aufrufen zu sehen ist; alles Weitere lädt
/// nach wie vor nach, und die Plakate dafür liegen ohnehin schon da.
public actor LibraryCache {
public static let shared = LibraryCache()
/// Der gespeicherte Stand einer Mediathek.
public struct Snapshot: Codable, Sendable {
/// Zu welcher Abfrage er gehört.
///
/// Eine nach Erscheinungsjahr sortierte Liste ist für eine
/// alphabetische Ansicht wertlos, und eine gefilterte erst recht.
/// Passt der Schlüssel nicht, gilt der Stand als nicht vorhanden.
public let queryKey: String
public let items: [MediaItem]
public let totalCount: Int
public let savedAt: Date
public init(queryKey: String, items: [MediaItem], totalCount: Int, savedAt: Date = .now) {
self.queryKey = queryKey
self.items = items
self.totalCount = totalCount
self.savedAt = savedAt
}
}
private let root: URL
private let logger = Logger(subsystem: "app.flaro", category: "LibraryCache")
/// Wie viele Titel höchstens gespeichert werden.
///
/// Eine Seite. Siehe oben, warum nicht mehr.
public static let pageLimit = 60
init(root: URL? = nil) {
self.root = root ?? FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appending(path: "LibraryCache", directoryHint: .isDirectory)
}
// MARK: - Lesen und Schreiben
public func snapshot(server: String, library: String, queryKey: String) -> Snapshot? {
guard let daten = try? Data(contentsOf: file(server: server, library: library)),
let stand = try? JSONDecoder().decode(Snapshot.self, from: daten)
else { return nil }
// Ein Stand zu einer anderen Sortierung ist kein Stand.
return stand.queryKey == queryKey ? stand : nil
}
public func store(_ snapshot: Snapshot, server: String, library: String) {
let gekürzt = Snapshot(
queryKey: snapshot.queryKey,
items: snapshot.items.prefix(Self.pageLimit).map(\.listEssentials),
totalCount: snapshot.totalCount,
savedAt: snapshot.savedAt
)
let ziel = file(server: server, library: library)
do {
try FileManager.default.createDirectory(
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
)
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
} catch {
// Ein misslungener Abzug ist kein Grund, irgendetwas abzubrechen —
// beim nächsten Mal wird eben geladen wie bisher.
logger.warning("Abzug nicht gespeichert: \(error.localizedDescription, privacy: .public)")
}
}
/// Die Liste der Mediatheken selbst.
///
/// Sie kommt beim Start, noch vor allem anderen, und ohne sie steht die
/// Seitenleiste leer.
public func libraries(server: String) -> [MediaLibrary]? {
guard let daten = try? Data(contentsOf: librariesFile(server: server)) else { return nil }
return try? JSONDecoder().decode([MediaLibrary].self, from: daten)
}
public func storeLibraries(_ libraries: [MediaLibrary], server: String) {
let ziel = librariesFile(server: server)
do {
try FileManager.default.createDirectory(
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
)
try JSONEncoder().encode(libraries).write(to: ziel, options: .atomic)
} catch {
logger.warning("Mediathekenliste nicht gespeichert: \(error.localizedDescription, privacy: .public)")
}
}
/// Die Reihen der Startseite.
///
/// **Weil es sonst nichts gibt, womit sie sich füllen liesse.** Die
/// Mediathekenliste und die Titel darin liegen schon auf der Platte; die
/// Startseite dagegen entstand bei jedem Aufruf neu am Server, und ohne
/// ihn blieb sie leer. Im Flugzeug ist das der Unterschied zwischen
/// „sehen, was es gibt" und einem Fehlerbildschirm.
public func homeRows(server: String) -> [MediaRowGroup]? {
guard let daten = try? Data(contentsOf: homeRowsFile(server: server)) else { return nil }
return try? JSONDecoder().decode([MediaRowGroup].self, from: daten)
}
public func storeHomeRows(_ rows: [MediaRowGroup], server: String) {
// Je Reihe eine Bildschirmbreite, mehr sieht ohnehin niemand, bevor
// der Server wieder antwortet.
let gekürzt = rows.map {
MediaRowGroup(
id: $0.id,
title: $0.title,
moreKey: $0.moreKey,
libraryID: $0.libraryID,
items: $0.items.prefix(20).map(\.listEssentials)
)
}
let ziel = homeRowsFile(server: server)
do {
try FileManager.default.createDirectory(
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
)
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
} catch {
logger.warning("Startseite 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")
}
private func homeRowsFile(server: String) -> URL {
directory(server: server).appending(path: "home.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: "|")
}
}