diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index bb58fdb..da64158 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -293,9 +293,9 @@ final class FlixySession { let server = PlexServer(resource: resource, connection: connection, identity: identity) else { return false } - adopt(server: server) + let plex = adopt(server: server) do { - libraries = try await server.libraries().filter(\.isSupported).map(\.asMediaLibrary) + libraries = try await loadLibraries(from: plex) phase = .ready return true } catch { @@ -416,6 +416,10 @@ final class FlixySession { forgetJellyfin() defaults.removeObject(forKey: Key.serverKind) clearActiveServer() + // Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was + // einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr + // verloren. + Task { await LibraryCache.shared.clearAll() } phase = .signedOut } @@ -471,11 +475,11 @@ final class FlixySession { return } - adopt(server: server) + let plex = adopt(server: server) remember(resource: resource, connection: connection) do { - libraries = try await server.libraries().filter(\.isSupported).map(\.asMediaLibrary) + libraries = try await loadLibraries(from: plex) phase = .ready } catch { phase = .failed(message: ErrorText.describe(error)) @@ -541,7 +545,7 @@ final class FlixySession { self.backend = backend do { - libraries = try await backend.libraries().filter(\.isSupported) + libraries = try await loadLibraries(from: backend) phase = .ready } catch { phase = .failed(message: ErrorText.describe(error)) @@ -565,7 +569,7 @@ final class FlixySession { isLocalConnection = await backend.isInLocalNetwork() do { - libraries = try await backend.libraries().filter(\.isSupported) + libraries = try await loadLibraries(from: backend) } catch { self.backend = nil jellyfinAccount = nil @@ -613,7 +617,7 @@ final class FlixySession { isLocalConnection = await backend.isInLocalNetwork() do { - libraries = try await backend.libraries().filter(\.isSupported) + libraries = try await loadLibraries(from: backend) phase = .ready return true } catch MediaBackendError.unauthorized { @@ -648,12 +652,42 @@ final class FlixySession { // MARK: - Der aktive Server - /// Setzt Plex als aktiven Server. - private func adopt(server: PlexServer) { + /// Setzt Plex als aktiven Server und gibt die Hülle zurück. + /// + /// Zurückgegeben, damit der Aufrufer sie benutzen kann, ohne sich ein + /// zweites Mal eine zu bauen — die trüge eine eigene Live-TV-Sitzung mit + /// sich, und dann wären zwei im Umlauf, die sich nichts voneinander + /// erzählen. + @discardableResult + private func adopt(server: PlexServer) -> PlexBackend { + let hülle = PlexBackend(server: server) kind = .plex self.server = server - backend = PlexBackend(server: server) + backend = hülle jellyfinAccount = nil + return hülle + } + + /// Holt die Mediathekenliste und merkt sie sich. + /// + /// **Der Speicher ist hier nur ein Rettungsanker**, kein Beschleuniger: + /// die Liste ist eine einzige kleine Anfrage, und sie sofort zu zeigen + /// hiesse, eine Sitzung für fertig zu erklären, bevor feststeht, dass der + /// Token noch gilt. Antwortet der Server dagegen **nicht**, ist eine + /// Seitenleiste von gestern besser als eine leere — im Zug ist das der + /// Unterschied zwischen „das Heruntergeladene ansehen" und „gar nichts". + private func loadLibraries(from backend: any MediaBackend) async throws -> [MediaLibrary] { + let kennung = backend.identity + do { + let geladen = try await backend.libraries().filter(\.isSupported) + await LibraryCache.shared.storeLibraries(geladen, server: kennung) + return geladen + } catch { + guard let gemerkt = await LibraryCache.shared.libraries(server: kennung), + !gemerkt.isEmpty + else { throw error } + return gemerkt + } } private func clearActiveServer() { diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 544d5fe..c59cde2 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -66,7 +66,14 @@ struct LibraryView: View { .sheet(isPresented: $showsFilters) { LibraryFilterSheet(query: $query, libraryKind: library.kind) } - .task(id: library.id) { await reload() } + .task(id: library.id) { + // Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält + // `reload()` die Titel der vorigen für die eigenen und fragt den + // Speicher gar nicht erst. + items = [] + page = nil + await reload() + } // Jede Änderung an Filter oder Sortierung verwirft die bisherige // Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei // verschiedenen Abfragen vermischen. @@ -241,19 +248,60 @@ struct LibraryView: View { // MARK: - Laden + /// Lädt die erste Seite — erst aus dem Speicher, dann vom Server. + /// + /// **Der gespeicherte Stand zuerst, ohne Ladekreis.** Die Plakate liegen + /// ohnehin schon auf der Platte; was fehlte, war die Liste, die sagt, + /// welche davon hierhergehören. Mit ihr steht die Mediathek sofort da. + /// + /// **Dann der Server, stillschweigend.** Kein zweiter Ladekreis über einer + /// Liste, die schon zu sehen ist — die Titel werden ersetzt, sobald die + /// Antwort da ist, und in aller Regel sieht man davon nichts, weil sich + /// nichts geändert hat. + /// + /// **Und wenn der Server nicht antwortet**, bleibt der gespeicherte Stand + /// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein + /// Fehlerbildschirm. private func reload() async { guard let server = session.backend else { return } - state = .loading + let schlüssel = query.cacheKey + let kennung = server.identity + + // Nur wenn nichts dasteht. Beim Herunterziehen und beim Filterwechsel + // ist das Gezeigte aktueller als jeder Abzug. + if items.isEmpty, + let stand = await LibraryCache.shared.snapshot( + server: kennung, library: library.id, queryKey: schlüssel + ) { + items = stand.items + page = MediaPage(items: stand.items, totalCount: stand.totalCount) + state = .loaded(()) + } else if items.isEmpty { + state = .loading + } do { let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60) items = first.items page = first + loadMoreFailed = false state = .loaded(()) + + await LibraryCache.shared.store( + LibraryCache.Snapshot( + queryKey: schlüssel, items: first.items, totalCount: first.totalCount + ), + server: kennung, + library: library.id + ) } catch { // Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet // sich als Übertragungsfehler, nicht als Abbruch. if Task.isCancelled { return } + // Steht schon etwas da — aus dem Speicher oder von vorhin —, dann + // bleibt es. Den Fehler über eine brauchbare Liste zu legen + // nähme dem Nutzer etwas weg, statt ihm etwas zu sagen. + guard items.isEmpty else { return } state = .failed(message: ErrorText.describe(error)) } } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index b3b68fa..aa96e66 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -10,6 +10,12 @@ public struct JellyfinBackend: MediaBackend { let images = JellyfinImagePaths() public let serverName: String + /// Konto **und** Adresse: dasselbe Konto auf zwei Servern ist nicht + /// dasselbe, und zwei Konten auf einem Server erst recht nicht. + public nonisolated var identity: String { + "jellyfin:\(client.baseURL.absoluteString):\(account.userID)" + } + public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) { self.client = client self.account = account diff --git a/Packages/FlixyKit/Sources/MediaKit/LibraryCache.swift b/Packages/FlixyKit/Sources/MediaKit/LibraryCache.swift new file mode 100644 index 0000000..5aafd18 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/LibraryCache.swift @@ -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.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: "|") + } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index febf5ff..be54ee1 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -9,6 +9,14 @@ import Foundation public protocol MediaBackend: Sendable { /// Wie der Server heißt, so wie er sich selbst nennt. nonisolated var serverName: String { get } + /// Wer dieser Server ist — für alles, was ihn über einen Start hinaus + /// wiedererkennen muss. + /// + /// **Nicht der Name.** Zwei Server dürfen „Medien" heißen, und derselbe + /// Server darf umbenannt werden. Was hier steht, ist so gewählt, dass es + /// beides überlebt und beim nächsten Start noch gleich lautet. Es ist + /// eine Kennung und geht nirgends an die Oberfläche. + nonisolated var identity: String { get } /// Was dieser Server darüber hinaus beherrscht. nonisolated var capabilities: Set { get } diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift b/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift index 91dcc95..a176a5e 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift @@ -11,7 +11,7 @@ import Foundation /// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist: /// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und /// aus dem „Großelternteil" ist die Serie geworden, die damit gemeint war. -public struct MediaItem: Sendable, Equatable, Identifiable { +public struct MediaItem: Sendable, Equatable, Identifiable, Codable { public enum Kind: String, Sendable, Codable, CaseIterable { case movie, show, season, episode, clip, collection } @@ -169,6 +169,49 @@ public struct MediaItem: Sendable, Equatable, Identifiable { } extension MediaItem { + /// Dasselbe ohne alles, was eine Kachel nicht zeigt. + /// + /// **Wozu.** Ein Titel aus einer Listenabfrage bringt seine Fassungen mit, + /// und die bringen jede Spur jeder Datei mit — Codec, Sprache, Kanäle. Im + /// Raster steht davon nichts: dort stehen Plakat, Titel, Untertitel und + /// der Fortschrittsbalken. Gespeichert wäre der Rest das Zwanzigfache an + /// Daten, die bei jedem Aufrufen gelesen und ausgepackt werden müssten — + /// und damit wäre der Speicher genau das nicht mehr, wofür es ihn gibt. + /// + /// **Warum das gefahrlos ist.** Aus einer Kachel führt nur ein Weg, und + /// der geht über die Kennung: die Detailansicht holt den Titel neu. Was + /// hier fehlt, fehlt nur so lange, bis der Server geantwortet hat. + var listEssentials: MediaItem { + MediaItem( + id: id, + path: path, + kind: kind, + title: title, + sortTitle: sortTitle, + year: year, + duration: duration, + contentRating: contentRating, + thumb: thumb, + art: art, + parentThumb: parentThumb, + seriesThumb: seriesThumb, + index: index, + parentIndex: parentIndex, + parentTitle: parentTitle, + parentID: parentID, + seriesTitle: seriesTitle, + seriesID: seriesID, + playCount: playCount, + resumeMilliseconds: resumeMilliseconds, + lastPlayedAt: lastPlayedAt, + addedAt: addedAt, + isFavourite: isFavourite, + episodeCount: episodeCount, + playedEpisodeCount: playedEpisodeCount, + childCount: childCount + ) + } + /// Wie weit gesehen — als Anteil zwischen 0 und 1. /// /// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent @@ -202,7 +245,7 @@ extension MediaItem { } /// Jemand, der mitgewirkt hat. -public struct MediaPerson: Sendable, Equatable, Identifiable { +public struct MediaPerson: Sendable, Equatable, Identifiable, Codable { public let id: String public let name: String /// Die Rolle, sofern es eine gibt. @@ -218,8 +261,8 @@ public struct MediaPerson: Sendable, Equatable, Identifiable { } /// Ein Abschnitt, den man überspringen möchte. -public struct MediaMarker: Sendable, Equatable, Identifiable { - public enum Kind: String, Sendable { +public struct MediaMarker: Sendable, Equatable, Identifiable, Codable { + public enum Kind: String, Sendable, Codable { case intro, credits, commercial, unknown } @@ -250,8 +293,8 @@ public struct MediaMarker: Sendable, Equatable, Identifiable { } /// Ein zusätzliches Bild zu einem Titel. -public struct MediaArtwork: Sendable, Equatable { - public enum Kind: String, Sendable { +public struct MediaArtwork: Sendable, Equatable, Codable { + public enum Kind: String, Sendable, Codable { case poster, background, banner, clearLogo, unknown } diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift b/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift index d61d650..2834b1b 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaLibrary.swift @@ -1,8 +1,8 @@ import Foundation /// Eine Mediathek auf dem Server. -public struct MediaLibrary: Sendable, Equatable, Identifiable { - public enum Kind: String, Sendable, Equatable { +public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable { + public enum Kind: String, Sendable, Equatable, Codable { case movie, show, music, photo, liveTV, other } diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift index 5904288..1ba4a26 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift @@ -4,7 +4,7 @@ import Foundation /// /// Ein Film kann mehrfach vorliegen: als 4K-Remux und als kleine Fassung fürs /// Handy. Welche gespielt wird, entscheidet der Nutzer oder die Voreinstellung. -public struct MediaVersion: Sendable, Equatable, Identifiable { +public struct MediaVersion: Sendable, Equatable, Identifiable, Codable { public let id: String public let container: String? public let videoCodec: String? @@ -93,7 +93,7 @@ public struct MediaVersion: Sendable, Equatable, Identifiable { } /// Eine Datei auf dem Server. -public struct MediaFile: Sendable, Equatable, Identifiable { +public struct MediaFile: Sendable, Equatable, Identifiable, Codable { public let id: String /// Der Pfad, über den die Datei abgerufen wird. public let key: String? @@ -150,8 +150,8 @@ public struct MediaFile: Sendable, Equatable, Identifiable { } /// Eine Spur in einer Datei. -public struct MediaStream: Sendable, Equatable, Identifiable { - public enum Kind: Sendable, Equatable { case video, audio, subtitle } +public struct MediaStream: Sendable, Equatable, Identifiable, Codable { + public enum Kind: String, Sendable, Equatable, Codable { case video, audio, subtitle } public let id: String public let kind: Kind diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift index edd6243..fcd97be 100644 --- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -28,6 +28,10 @@ public struct PlexBackend: MediaBackend { public nonisolated var serverName: String { server.name } + /// Die Maschinenkennung — sie überlebt Umbenennen und Adresswechsel, + /// genau dafür gibt es sie. + public nonisolated var identity: String { "plex:\(server.resource.clientIdentifier)" } + public nonisolated var capabilities: Set { // Keine Favoriten: Plex kennt sie nicht, und ein Knopf, der nichts // bewirkt, ist schlimmer als keiner. diff --git a/Packages/FlixyKit/Tests/MediaKitTests/LibraryCacheTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/LibraryCacheTests.swift new file mode 100644 index 0000000..33e7a46 --- /dev/null +++ b/Packages/FlixyKit/Tests/MediaKitTests/LibraryCacheTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing +@testable import MediaKit + +@Suite("Gespeicherte Mediatheken") +struct LibraryCacheTests { + + /// Jeder Test bekommt ein eigenes Verzeichnis. Ein gemeinsames hiesse, + /// dass die Reihenfolge der Tests über ihr Ergebnis mitentscheidet. + private func frischerSpeicher() -> LibraryCache { + let ort = URL.temporaryDirectory.appending(path: "flixy-cache-\(UUID().uuidString)") + return LibraryCache(root: ort) + } + + private func titel(_ id: String, _ name: String) -> MediaItem { + MediaItem(id: id, kind: .movie, title: name, year: 1999, thumb: "/bild/\(id)") + } + + @Test("Was hineingeht, kommt heraus") + func roundTrip() async { + let speicher = frischerSpeicher() + let abzug = LibraryCache.Snapshot( + queryKey: "titleAscending||||||", + items: [titel("1", "Matrix"), titel("2", "Fight Club")], + totalCount: 412 + ) + + await speicher.store(abzug, server: "jellyfin:daheim", library: "filme") + let zurück = await speicher.snapshot( + server: "jellyfin:daheim", library: "filme", queryKey: "titleAscending||||||" + ) + + #expect(zurück?.items.map(\.title) == ["Matrix", "Fight Club"]) + // Ohne die Gesamtzahl wüsste die Ansicht nicht, ob noch etwas kommt. + #expect(zurück?.totalCount == 412) + #expect(zurück?.items.first?.thumb == "/bild/1") + } + + /// Der Fall, der stillschweigend falsche Listen zeigen würde: derselbe + /// Ort, andere Sortierung. + @Test("Ein Abzug zu einer anderen Abfrage gilt nicht") + func wrongQueryIsNoAnswer() async { + let speicher = frischerSpeicher() + await speicher.store( + LibraryCache.Snapshot(queryKey: "titleAscending||||||", items: [titel("1", "A")], totalCount: 1), + server: "s", library: "l" + ) + + #expect(await speicher.snapshot(server: "s", library: "l", queryKey: "recentlyAdded||||||") == nil) + } + + @Test("Ohne Abzug kommt nichts") + func emptyIsEmpty() async { + #expect(await frischerSpeicher().snapshot(server: "s", library: "l", queryKey: "x") == nil) + } + + @Test("Zwei Mediatheken kommen sich nicht ins Gehege") + func librariesAreSeparate() async { + let speicher = frischerSpeicher() + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Film")], totalCount: 1), + server: "s", library: "filme" + ) + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [titel("2", "Serie")], totalCount: 1), + server: "s", library: "serien" + ) + + #expect(await speicher.snapshot(server: "s", library: "filme", queryKey: "k")?.items.first?.title == "Film") + #expect(await speicher.snapshot(server: "s", library: "serien", queryKey: "k")?.items.first?.title == "Serie") + } + + /// Dieselbe Mediathek auf zwei Servern hat dieselbe Kennung. Ohne Trennung + /// nach Server zeigte der eine die Titel des anderen. + @Test("Zwei Server kommen sich nicht ins Gehege") + func serversAreSeparate() async { + let speicher = frischerSpeicher() + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Daheim")], totalCount: 1), + server: "jellyfin:daheim", library: "1" + ) + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Fremd")], totalCount: 1), + server: "jellyfin:fremd", library: "1" + ) + + #expect(await speicher.snapshot(server: "jellyfin:daheim", library: "1", queryKey: "k")?.items.first?.title == "Daheim") + } + + @Test("Mehr als eine Seite wird nicht behalten") + func onlyOnePage() async { + let speicher = frischerSpeicher() + let viele = (0..<500).map { titel(String($0), "Titel \($0)") } + + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: viele, totalCount: 500), + server: "s", library: "l" + ) + + let zurück = await speicher.snapshot(server: "s", library: "l", queryKey: "k") + #expect(zurück?.items.count == LibraryCache.pageLimit) + // Die Gesamtzahl bleibt, sonst hielte die Ansicht sechzig für alles. + #expect(zurück?.totalCount == 500) + } + + /// Was eine Kachel nicht zeigt, wird nicht gespeichert — sonst wäre jeder + /// Abzug das Zwanzigfache gross und müsste beim Aufrufen ausgepackt + /// werden, also genau da, wo er Zeit sparen soll. + @Test("Gespeichert wird nur, was eine Kachel zeigt") + func storesOnlyWhatATileShows() async { + let speicher = frischerSpeicher() + let voll = MediaItem( + id: "1", + kind: .movie, + title: "Matrix", + summary: "Ein langer Text, der im Raster nirgends steht.", + duration: 120_000, + thumb: "/bild/1", + resumeMilliseconds: 60_000, + versions: [MediaVersion(id: "v1", videoCodec: "hevc", width: 3_840, height: 2_160)], + genres: ["Science-Fiction"], + cast: [MediaPerson(id: "p1", name: "Jemand")] + ) + + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [voll], totalCount: 1), + server: "s", library: "l" + ) + let zurück = try! #require( + await speicher.snapshot(server: "s", library: "l", queryKey: "k") + ).items[0] + + // Was die Kachel braucht, ist da. + #expect(zurück.title == "Matrix") + #expect(zurück.thumb == "/bild/1") + #expect(zurück.watchProgress == 0.5) + // Was sie nicht braucht, ist weg. + #expect(zurück.versions.isEmpty) + #expect(zurück.cast.isEmpty) + #expect(zurück.genres.isEmpty) + #expect(zurück.summary == nil) + } + + @Test("Abmelden räumt alles weg") + func signOutClears() async { + let speicher = frischerSpeicher() + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "A")], totalCount: 1), + server: "s", library: "l" + ) + await speicher.storeLibraries([MediaLibrary(id: "l", title: "Filme", kind: .movie)], server: "s") + + await speicher.clearAll() + + #expect(await speicher.snapshot(server: "s", library: "l", queryKey: "k") == nil) + #expect(await speicher.libraries(server: "s") == nil) + } + + @Test("Einen Server vergessen lässt den anderen stehen") + func clearIsPerServer() async { + let speicher = frischerSpeicher() + for server in ["a", "b"] { + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [titel("1", server)], totalCount: 1), + server: server, library: "l" + ) + } + + await speicher.clear(server: "a") + + #expect(await speicher.snapshot(server: "a", library: "l", queryKey: "k") == nil) + #expect(await speicher.snapshot(server: "b", library: "l", queryKey: "k") != nil) + } + + @Test("Die Mediathekenliste überlebt") + func libraryListRoundTrip() async { + let speicher = frischerSpeicher() + let listen = [ + MediaLibrary(id: "1", title: "Filme", kind: .movie, thumb: "/a"), + MediaLibrary(id: "2", title: "Serien", kind: .show), + ] + + await speicher.storeLibraries(listen, server: "s") + + #expect(await speicher.libraries(server: "s") == listen) + } + + // MARK: - Der Schlüssel + + /// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf; ein damit + /// benannter Abzug wäre nach jedem Neustart unauffindbar, ohne dass + /// irgendetwas kaputt aussähe. + @Test("Derselbe Filter ergibt denselben Schlüssel") + func keyIsStable() { + let a = MediaQuery(sort: .recentlyAdded, unwatchedOnly: true, genre: "Krimi") + let b = MediaQuery(sort: .recentlyAdded, unwatchedOnly: true, genre: "Krimi") + #expect(a.cacheKey == b.cacheKey) + } + + @Test("Jeder Unterschied ändert den Schlüssel") + func keySeparatesQueries() { + let grund = MediaQuery() + #expect(MediaQuery(sort: .recentlyAdded).cacheKey != grund.cacheKey) + #expect(MediaQuery(unwatchedOnly: true).cacheKey != grund.cacheKey) + #expect(MediaQuery(favouritesOnly: true).cacheKey != grund.cacheKey) + #expect(MediaQuery(genre: "Krimi").cacheKey != grund.cacheKey) + #expect(MediaQuery(year: 1999).cacheKey != grund.cacheKey) + #expect(MediaQuery(contentRating: "FSK 16").cacheKey != grund.cacheKey) + #expect(MediaQuery(resolution: "4k").cacheKey != grund.cacheKey) + #expect(MediaQuery(searchText: "Matrix").cacheKey != grund.cacheKey) + } + + /// Kennungen kommen vom Server — Jellyfin-GUIDs, und in der + /// Serverkennung steckt eine Adresse mit Schrägstrichen. + @Test("Kennungen mit Schrägstrichen ergeben brauchbare Dateinamen") + func nameIsFilesystemSafe() async { + let speicher = frischerSpeicher() + let wild = "jellyfin:https://medien.beispiel.tld:8096/:a+b/c" + + await speicher.store( + LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "A")], totalCount: 1), + server: wild, library: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6" + ) + + #expect( + await speicher.snapshot( + server: wild, library: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", queryKey: "k" + )?.items.count == 1 + ) + #expect(!LibraryCache.safeName(wild).contains("/")) + } +}