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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,174 @@
|
||||
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: "|")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user