Mediatheken stehen sofort da statt sich jedes Mal neu zu laden

Jeder Aufruf einer Mediathek war ein Serverabruf mit Ladekreis davor. Die
Plakate lagen längst auf der Platte; was fehlte, war die Liste, die sagt,
welche davon hierhergehören.

Jetzt liegt die erste Seite als Abzug daneben: beim Aufrufen steht sie
sofort da, der Server wird still nachgefragt, und man sieht in aller Regel
nichts davon. Antwortet er nicht, bleibt der Abzug stehen — eine Liste von
gestern ist im Zug brauchbarer als ein Fehlerbildschirm.

Bewusst nur die erste Seite. Stünden 240 gespeicherte Titel da und der
Server antwortet mit 60, müsste entweder die Liste vor den Augen schrumpfen
oder der Versatz fürs Nachladen wäre falsch.

Gespeichert wird ausserdem nur, was eine Kachel zeigt. Ein Titel aus einer
Listenabfrage bringt jede Spur jeder Datei mit — im Raster steht davon
nichts, und ausgepackt werden müsste es bei jedem Aufrufen, also genau da,
wo der Abzug Zeit sparen soll.

Die Mediathekenliste selbst wird nur als Rettungsanker gemerkt, nicht als
Beschleuniger: sie sofort zu zeigen hiesse, eine Sitzung für fertig zu
erklären, bevor feststeht, dass der Token noch gilt.

Abmelden räumt alles weg. Titel, Zusammenfassungen und Sehfortschritt sind
persönlich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 15:59:15 +02:00
co-authored by Claude Opus 5
parent 18958aadbc
commit 2dbe260901
10 changed files with 573 additions and 24 deletions
+44 -10
View File
@@ -293,9 +293,9 @@ final class FlixySession {
let server = PlexServer(resource: resource, connection: connection, identity: identity) let server = PlexServer(resource: resource, connection: connection, identity: identity)
else { return false } else { return false }
adopt(server: server) let plex = adopt(server: server)
do { do {
libraries = try await server.libraries().filter(\.isSupported).map(\.asMediaLibrary) libraries = try await loadLibraries(from: plex)
phase = .ready phase = .ready
return true return true
} catch { } catch {
@@ -416,6 +416,10 @@ final class FlixySession {
forgetJellyfin() forgetJellyfin()
defaults.removeObject(forKey: Key.serverKind) defaults.removeObject(forKey: Key.serverKind)
clearActiveServer() 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 phase = .signedOut
} }
@@ -471,11 +475,11 @@ final class FlixySession {
return return
} }
adopt(server: server) let plex = adopt(server: server)
remember(resource: resource, connection: connection) remember(resource: resource, connection: connection)
do { do {
libraries = try await server.libraries().filter(\.isSupported).map(\.asMediaLibrary) libraries = try await loadLibraries(from: plex)
phase = .ready phase = .ready
} catch { } catch {
phase = .failed(message: ErrorText.describe(error)) phase = .failed(message: ErrorText.describe(error))
@@ -541,7 +545,7 @@ final class FlixySession {
self.backend = backend self.backend = backend
do { do {
libraries = try await backend.libraries().filter(\.isSupported) libraries = try await loadLibraries(from: backend)
phase = .ready phase = .ready
} catch { } catch {
phase = .failed(message: ErrorText.describe(error)) phase = .failed(message: ErrorText.describe(error))
@@ -565,7 +569,7 @@ final class FlixySession {
isLocalConnection = await backend.isInLocalNetwork() isLocalConnection = await backend.isInLocalNetwork()
do { do {
libraries = try await backend.libraries().filter(\.isSupported) libraries = try await loadLibraries(from: backend)
} catch { } catch {
self.backend = nil self.backend = nil
jellyfinAccount = nil jellyfinAccount = nil
@@ -613,7 +617,7 @@ final class FlixySession {
isLocalConnection = await backend.isInLocalNetwork() isLocalConnection = await backend.isInLocalNetwork()
do { do {
libraries = try await backend.libraries().filter(\.isSupported) libraries = try await loadLibraries(from: backend)
phase = .ready phase = .ready
return true return true
} catch MediaBackendError.unauthorized { } catch MediaBackendError.unauthorized {
@@ -648,12 +652,42 @@ final class FlixySession {
// MARK: - Der aktive Server // MARK: - Der aktive Server
/// Setzt Plex als aktiven Server. /// Setzt Plex als aktiven Server und gibt die Hülle zurück.
private func adopt(server: PlexServer) { ///
/// 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 kind = .plex
self.server = server self.server = server
backend = PlexBackend(server: server) backend = hülle
jellyfinAccount = nil 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() { private func clearActiveServer() {
+49 -1
View File
@@ -66,7 +66,14 @@ struct LibraryView: View {
.sheet(isPresented: $showsFilters) { .sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, libraryKind: library.kind) 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 // Jede Änderung an Filter oder Sortierung verwirft die bisherige
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei // Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen. // verschiedenen Abfragen vermischen.
@@ -241,19 +248,60 @@ struct LibraryView: View {
// MARK: - Laden // 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 { private func reload() async {
guard let server = session.backend else { return } guard let server = session.backend else { return }
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 state = .loading
}
do { do {
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60) let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
items = first.items items = first.items
page = first page = first
loadMoreFailed = false
state = .loaded(()) state = .loaded(())
await LibraryCache.shared.store(
LibraryCache.Snapshot(
queryKey: schlüssel, items: first.items, totalCount: first.totalCount
),
server: kennung,
library: library.id
)
} catch { } catch {
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet // Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
// sich als Übertragungsfehler, nicht als Abbruch. // sich als Übertragungsfehler, nicht als Abbruch.
if Task.isCancelled { return } 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)) state = .failed(message: ErrorText.describe(error))
} }
} }
@@ -10,6 +10,12 @@ public struct JellyfinBackend: MediaBackend {
let images = JellyfinImagePaths() let images = JellyfinImagePaths()
public let serverName: String 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) { public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) {
self.client = client self.client = client
self.account = account self.account = account
@@ -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: "|")
}
}
@@ -9,6 +9,14 @@ import Foundation
public protocol MediaBackend: Sendable { public protocol MediaBackend: Sendable {
/// Wie der Server heißt, so wie er sich selbst nennt. /// Wie der Server heißt, so wie er sich selbst nennt.
nonisolated var serverName: String { get } 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. /// Was dieser Server darüber hinaus beherrscht.
nonisolated var capabilities: Set<BackendCapability> { get } nonisolated var capabilities: Set<BackendCapability> { get }
@@ -11,7 +11,7 @@ import Foundation
/// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist: /// `summary`, `year`, `index`. Umbenannt wurde nur, was Plex-Jargon ist:
/// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und /// `ratingKey` heißt jetzt `id`, `viewOffset` heißt `resumeMilliseconds`, und
/// aus dem „Großelternteil" ist die Serie geworden, die damit gemeint war. /// 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 { public enum Kind: String, Sendable, Codable, CaseIterable {
case movie, show, season, episode, clip, collection case movie, show, season, episode, clip, collection
} }
@@ -169,6 +169,49 @@ public struct MediaItem: Sendable, Equatable, Identifiable {
} }
extension MediaItem { 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. /// Wie weit gesehen — als Anteil zwischen 0 und 1.
/// ///
/// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent /// `nil`, wenn noch nicht begonnen wurde: ein Balken bei null Prozent
@@ -202,7 +245,7 @@ extension MediaItem {
} }
/// Jemand, der mitgewirkt hat. /// Jemand, der mitgewirkt hat.
public struct MediaPerson: Sendable, Equatable, Identifiable { public struct MediaPerson: Sendable, Equatable, Identifiable, Codable {
public let id: String public let id: String
public let name: String public let name: String
/// Die Rolle, sofern es eine gibt. /// Die Rolle, sofern es eine gibt.
@@ -218,8 +261,8 @@ public struct MediaPerson: Sendable, Equatable, Identifiable {
} }
/// Ein Abschnitt, den man überspringen möchte. /// Ein Abschnitt, den man überspringen möchte.
public struct MediaMarker: Sendable, Equatable, Identifiable { public struct MediaMarker: Sendable, Equatable, Identifiable, Codable {
public enum Kind: String, Sendable { public enum Kind: String, Sendable, Codable {
case intro, credits, commercial, unknown case intro, credits, commercial, unknown
} }
@@ -250,8 +293,8 @@ public struct MediaMarker: Sendable, Equatable, Identifiable {
} }
/// Ein zusätzliches Bild zu einem Titel. /// Ein zusätzliches Bild zu einem Titel.
public struct MediaArtwork: Sendable, Equatable { public struct MediaArtwork: Sendable, Equatable, Codable {
public enum Kind: String, Sendable { public enum Kind: String, Sendable, Codable {
case poster, background, banner, clearLogo, unknown case poster, background, banner, clearLogo, unknown
} }
@@ -1,8 +1,8 @@
import Foundation import Foundation
/// Eine Mediathek auf dem Server. /// Eine Mediathek auf dem Server.
public struct MediaLibrary: Sendable, Equatable, Identifiable { public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
public enum Kind: String, Sendable, Equatable { public enum Kind: String, Sendable, Equatable, Codable {
case movie, show, music, photo, liveTV, other case movie, show, music, photo, liveTV, other
} }
@@ -4,7 +4,7 @@ import Foundation
/// ///
/// Ein Film kann mehrfach vorliegen: als 4K-Remux und als kleine Fassung fürs /// Ein Film kann mehrfach vorliegen: als 4K-Remux und als kleine Fassung fürs
/// Handy. Welche gespielt wird, entscheidet der Nutzer oder die Voreinstellung. /// 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 id: String
public let container: String? public let container: String?
public let videoCodec: String? public let videoCodec: String?
@@ -93,7 +93,7 @@ public struct MediaVersion: Sendable, Equatable, Identifiable {
} }
/// Eine Datei auf dem Server. /// Eine Datei auf dem Server.
public struct MediaFile: Sendable, Equatable, Identifiable { public struct MediaFile: Sendable, Equatable, Identifiable, Codable {
public let id: String public let id: String
/// Der Pfad, über den die Datei abgerufen wird. /// Der Pfad, über den die Datei abgerufen wird.
public let key: String? public let key: String?
@@ -150,8 +150,8 @@ public struct MediaFile: Sendable, Equatable, Identifiable {
} }
/// Eine Spur in einer Datei. /// Eine Spur in einer Datei.
public struct MediaStream: Sendable, Equatable, Identifiable { public struct MediaStream: Sendable, Equatable, Identifiable, Codable {
public enum Kind: Sendable, Equatable { case video, audio, subtitle } public enum Kind: String, Sendable, Equatable, Codable { case video, audio, subtitle }
public let id: String public let id: String
public let kind: Kind public let kind: Kind
@@ -28,6 +28,10 @@ public struct PlexBackend: MediaBackend {
public nonisolated var serverName: String { server.name } 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<BackendCapability> { public nonisolated var capabilities: Set<BackendCapability> {
// Keine Favoriten: Plex kennt sie nicht, und ein Knopf, der nichts // Keine Favoriten: Plex kennt sie nicht, und ein Knopf, der nichts
// bewirkt, ist schlimmer als keiner. // bewirkt, ist schlimmer als keiner.
@@ -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("/"))
}
}