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:
co-authored by
Claude Opus 5
parent
18958aadbc
commit
2dbe260901
@@ -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 {
|
||||
/// 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<BackendCapability> { get }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user