Files
Flaro/Packages/HaloKit/Sources/MediaKit/LibraryCache.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

175 lines
7.0 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.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: "|")
}
}