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