From 8f0568cb6c4675f2bafe7922981e8f150a919fcf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 12 Oct 2026 19:41:22 +0200 Subject: [PATCH] =?UTF-8?q?Ohne=20Server=20stand=20man=20vor=20der=20T?= =?UTF-8?q?=C3=BCr,=20mit=20dem=20Film=20in=20der=20Tasche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fiel der Server aus, endete der Start bei einem Fehlerbildschirm mit zwei Knöpfen: „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug nichts — und dahinter lagen die Dateien, die man genau für diesen Fall heruntergeladen hatte. Jetzt geht die App trotzdem auf: Token und Konto liegen auf dem Gerät, die Mediathekenliste auch, und die Startseite kommt neuerdings aus demselben Speicher. Oben die Downloads, darunter der Stand vom letzten Mal, ausgegraut, mit einem Satz, der sagt warum. Die Detailseite nimmt den Titel vom Gerät, wenn der Server schweigt, und der Abspielknopf bleibt an, weil die Datei ja da ist. Und was man offline geschaut hat, zählt: Fortschritt, den der Server nicht annimmt, wird zurückgelegt und nachgereicht, sobald er wieder antwortet. Dafür sagt `reportProgress` jetzt, ob die Meldung ankam. Co-Authored-By: Claude Opus 5 --- Flaro/FlaroApp.swift | 6 + Flaro/Resources/Localizable.xcstrings | 16 +++ Flaro/Session/FlaroSession.swift | 42 +++++- Flaro/Session/Heruntergeladenes.swift | 32 ++++- Flaro/Session/HomeFeed.swift | 32 ++++- Flaro/Views/Detail/MediaDetailView.swift | 27 +++- Flaro/Views/Home/HomeView.swift | 60 ++++++++- Flaro/Views/Navigation/RootView.swift | 2 + Flaro/Views/Player/PlayerModel.swift | 15 ++- .../Sources/FlaroServerKit/FlaroBackend.swift | 15 ++- .../JellyfinBackend+Playback.swift | 21 +-- .../Sources/MediaKit/Fortschrittspost.swift | 126 ++++++++++++++++++ .../Sources/MediaKit/LibraryCache.swift | 39 ++++++ .../Sources/MediaKit/MediaBackend.swift | 35 +++-- .../Sources/MediaKit/MediaLibrary.swift | 2 +- .../MediaKitTests/FortschrittspostTests.swift | 117 ++++++++++++++++ .../MediaKitTests/LibraryCacheHomeTests.swift | 65 +++++++++ 17 files changed, 613 insertions(+), 39 deletions(-) create mode 100644 Packages/FlaroKit/Sources/MediaKit/Fortschrittspost.swift create mode 100644 Packages/FlaroKit/Tests/MediaKitTests/FortschrittspostTests.swift create mode 100644 Packages/FlaroKit/Tests/MediaKitTests/LibraryCacheHomeTests.swift diff --git a/Flaro/FlaroApp.swift b/Flaro/FlaroApp.swift index 547c11e..e06811e 100644 --- a/Flaro/FlaroApp.swift +++ b/Flaro/FlaroApp.swift @@ -36,6 +36,9 @@ struct FlaroApp: App { @State private var network = NetworkConditions() @State private var homeLayout = HomeLayoutSettings() @State private var diagnostics = PlaybackDiagnostics() + /// Fortschritt, der den Server nicht erreicht hat — siehe + /// `Fortschrittspost`. Hier, weil er die Wiedergabe überleben muss. + @State private var post = Fortschrittspost.shared @State private var downloads: DownloadCoordinator private let modelContainer: ModelContainer @@ -92,6 +95,7 @@ struct FlaroApp: App { .environment(downloads) .environment(homeLayout) .environment(diagnostics) + .environment(post) .environment(theme) // Akzentfarbe und Tönung setzt RootView, nicht diese Stelle. // @@ -117,6 +121,8 @@ struct FlaroApp: App { backend: session.backend, coordinator: downloads ).refill() + // Was im Flugzeug liegengeblieben ist, geht jetzt raus. + await post.zustellen(über: session.backend) } } .modelContainer(modelContainer) diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings index 1fcdbaa..3316577 100644 --- a/Flaro/Resources/Localizable.xcstrings +++ b/Flaro/Resources/Localizable.xcstrings @@ -2146,6 +2146,22 @@ } } }, + "home.offline" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No server reachable. What you see is from last time — only what is on this device can be played." + } + } + } + }, "home.downloaded" : { "localizations" : { "de" : { diff --git a/Flaro/Session/FlaroSession.swift b/Flaro/Session/FlaroSession.swift index 74fae24..262dbf0 100644 --- a/Flaro/Session/FlaroSession.swift +++ b/Flaro/Session/FlaroSession.swift @@ -283,6 +283,9 @@ final class FlaroSession { // einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr // verloren. Task { await LibraryCache.shared.clearAll() } + // Dasselbe gilt für den Fortschritt, der noch auf den Server + // wartet: er gehört dem Konto, das gerade gegangen ist. + Fortschrittspost.shared.leeren() phase = .signedOut } @@ -472,14 +475,45 @@ final class FlaroSession { // Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder // Server das, und es ist kein Grund, jemanden abzumelden. // - // **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen - // kein Konto da, und das Heruntergeladene wäre unerreichbar. - phase = .failed(message: ErrorText.describe(error)) - activeKind = .flaro + // **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher + // endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen: + // „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug + // nichts, und das Heruntergeladene lag unerreichbar dahinter. + // + // Der Token liegt im Schlüsselbund, das Konto in den + // Voreinstellungen — beides reicht, um die App zu betreten. + // Jede einzelne Anfrage scheitert dann weiterhin, und jeder + // Bildschirm sagt das für sich. + await adoptFlaro(client: FlaroClient( + baseURL: baseURL, identity: flaroIdentity, token: token + ), account: gemerktesFlaroKonto(baseURL: baseURL)) + if case .failed = phase { + // Nicht einmal eine Mediathekenliste gemerkt — dann ist die + // App leer, und der Fehlerbildschirm ist die ehrlichere + // Antwort. + phase = .failed(message: ErrorText.describe(error)) + activeKind = .flaro + } return true } } + /// Das Konto aus den Voreinstellungen, ohne den Server zu fragen. + /// + /// Alles darin wurde beim letzten erfolgreichen Anmelden geschrieben. + /// Es ist vielleicht veraltet — ein umbenannter Server, ein geänderter + /// Anzeigename —, aber es ist richtiger als gar keins. + private func gemerktesFlaroKonto(baseURL: URL) -> FlaroAccount { + FlaroAccount( + baseURL: baseURL, + serverName: defaults.string(forKey: Key.flaroServerName) + ?? baseURL.host() ?? "Flaro", + userID: defaults.string(forKey: Key.flaroUserID) ?? "", + userName: defaults.string(forKey: Key.flaroUserName) ?? "", + isAdministrator: defaults.bool(forKey: Key.flaroIsAdministrator) + ) + } + private func rememberFlaro(account: FlaroAccount, token: String?) { defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind) defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress) diff --git a/Flaro/Session/Heruntergeladenes.swift b/Flaro/Session/Heruntergeladenes.swift index ca02e2d..12a1ee0 100644 --- a/Flaro/Session/Heruntergeladenes.swift +++ b/Flaro/Session/Heruntergeladenes.swift @@ -43,7 +43,37 @@ enum Heruntergeladenes { parentIndex: eintrag.seasonIndex, parentTitle: eintrag.seriesTitle, seriesTitle: eintrag.seriesTitle, - seriesID: eintrag.seriesKey + seriesID: eintrag.seriesKey, + versions: [fassung(eintrag)] + ) + } + + /// Die Datei auf dem Gerät als Fassung. + /// + /// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet + /// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein + /// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die + /// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen + /// über sie gesagt hat, steht in `offlineMediaData`. + private static func fassung(_ eintrag: DownloadItem) -> MediaVersion { + let angaben = eintrag.offlineMediaData.flatMap { + try? JSONDecoder().decode(OfflineMedia.self, from: $0) + } + return MediaVersion( + id: eintrag.ratingKey, + container: angaben?.container, + videoCodec: angaben?.videoCodec, + audioCodec: angaben?.audioCodec, + videoResolution: angaben?.videoResolution, + bitrate: angaben?.bitrate, + duration: eintrag.durationMilliseconds, + files: [MediaFile( + id: eintrag.ratingKey, + itemID: eintrag.ratingKey, + container: angaben?.container, + size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil, + duration: eintrag.durationMilliseconds + )] ) } diff --git a/Flaro/Session/HomeFeed.swift b/Flaro/Session/HomeFeed.swift index 50f51ab..815da0b 100644 --- a/Flaro/Session/HomeFeed.swift +++ b/Flaro/Session/HomeFeed.swift @@ -20,6 +20,14 @@ import Observation final class HomeFeed { private(set) var state: LoadState<[MediaRowGroup]> = .loading + /// Ob das Gezeigte vom letzten Mal stammt, weil der Server nicht + /// antwortet. + /// + /// Die Startseite ist dann **nicht** falsch, nur alt — und das muss + /// dranstehen. Ein Stand von gestern, der sich als heute ausgibt, ist + /// schlimmer als gar keiner. + private(set) var ausDemSpeicher = false + /// Holt die Reihen vom Server. /// /// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe @@ -30,6 +38,24 @@ final class HomeFeed { state = await LoadState.load(keepingPrevious: state) { try await backend.homeRows() } + + switch state { + case .loaded(let reihen): + ausDemSpeicher = false + await LibraryCache.shared.storeHomeRows(reihen, server: backend.identity) + case .failed: + // **Der Abzug erst hier, nicht vorweg.** Ihn sofort zu zeigen + // hiesse, bei jedem Start eine alte Startseite aufzublitzen, die + // eine Sekunde später ersetzt wird. Er ist ein Rettungsanker, + // kein Beschleuniger. + if let gemerkt = await LibraryCache.shared.homeRows(server: backend.identity), + !gemerkt.isEmpty { + state = .loaded(gemerkt) + ausDemSpeicher = true + } + case .loading: + break + } #if os(tvOS) // Das Regal über der App im Menü. Es wird gezeichnet, wenn die App // **nicht** läuft — also legen wir hier ab, was dort stehen soll. @@ -50,9 +76,11 @@ final class HomeFeed { /// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads /// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede /// andere, nur mit einer anderen Quelle. + /// **Auch ohne geladene Reihen**: scheitert der Server und liegt nichts + /// im Speicher, bleibt immer noch, was auf dem Gerät liegt. func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] { - guard case .loaded(let alle) = state else { return [] } - var reihen = prepared(alle, layout: layout) + var reihen: [MediaRowGroup] = [] + if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) } if !downloaded.isEmpty { reihen.insert( MediaRowGroup( diff --git a/Flaro/Views/Detail/MediaDetailView.swift b/Flaro/Views/Detail/MediaDetailView.swift index 72af4d5..17a6ce3 100644 --- a/Flaro/Views/Detail/MediaDetailView.swift +++ b/Flaro/Views/Detail/MediaDetailView.swift @@ -1,5 +1,6 @@ import SwiftUI import FlaroDesign +import FlaroDownloads import MediaKit /// Die Detailseite eines Titels. @@ -11,6 +12,7 @@ struct MediaDetailView: View { let route: MediaRoute @Environment(FlaroSession.self) private var session + @Environment(DownloadCoordinator.self) private var downloads @State private var state: LoadState = .loading var body: some View { @@ -50,10 +52,33 @@ struct MediaDetailView: View { .task(id: route.ratingKey) { await load() } } + /// Holt den Titel — vom Server, und sonst vom Gerät. + /// + /// **Sonst führte ein Download ins Leere.** Ausserhalb von + /// „Fortsetzen" öffnet ein Tipp auf eine Kachel diese Seite; ohne + /// Server endete sie bei einem Fehlerbild, und die Datei, die daneben + /// auf der Platte lag, war von hier aus nicht mehr zu erreichen. + /// + /// Der Server bleibt massgeblich: nur wenn er nicht antwortet, tritt + /// das Wenige ein, was ein Download über sich selbst weiss. private func load() async { - guard let server = session.backend else { return } + guard let server = session.backend else { + state = vomGeraet.map { .loaded($0) } + ?? .failed(message: String(localized: "error.serverUnreachable")) + return + } state = await LoadState.load { try await server.item(id: route.ratingKey) } + if case .failed(let message) = state { + state = vomGeraet.map { .loaded($0) } ?? .failed(message: message) + } + } + + /// Was über diesen Titel auf dem Gerät liegt. + private var vomGeraet: MediaItem? { + downloads.items + .first { $0.isComplete && $0.ratingKey == route.ratingKey } + .map { Heruntergeladenes.titel($0, in: downloads) } } } diff --git a/Flaro/Views/Home/HomeView.swift b/Flaro/Views/Home/HomeView.swift index c4ba677..e0d702a 100644 --- a/Flaro/Views/Home/HomeView.swift +++ b/Flaro/Views/Home/HomeView.swift @@ -13,6 +13,7 @@ struct HomeView: View { @Environment(FlaroSession.self) private var session @Environment(HomeLayoutSettings.self) private var layout @Environment(DownloadCoordinator.self) private var downloads + @Environment(Fortschrittspost.self) private var post @State private var feed = HomeFeed() /// Was auf dem Gerät liegt, als Reihe ganz oben. @@ -32,7 +33,7 @@ struct HomeView: View { case .loading: ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) - case .failed(let message): + case .failed(let message) where heruntergeladen.isEmpty: ContentUnavailableView { Label("error.title", systemImage: "exclamationmark.triangle") } description: { @@ -41,7 +42,11 @@ struct HomeView: View { Button("action.retry") { Task { await feed.load(from: session.backend) } } } - case .loaded: + // **Mit Downloads ist „kein Server" kein Fehlerbildschirm.** + // Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen — + // genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu + // stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat. + case .failed, .loaded: let hubs = feed.rows(for: layout, downloaded: heruntergeladen) if hubs.isEmpty { ContentUnavailableView { @@ -79,6 +84,10 @@ struct HomeView: View { // würde. await DownloadService(backend: session.backend, coordinator: downloads) .plakateNachholen() + // **Der Server hat geantwortet — dann nimmt er auch Post an.** + // Der Start der App ist dafür der falsche Zeitpunkt: da ist oft + // noch gar keine Verbindung. + if !feed.ausDemSpeicher { await post.zustellen(über: session.backend) } } .refreshable { await feed.load(from: session.backend) } // Was eine Kachel am Server ändert, ändert auch, was hier steht: @@ -89,13 +98,22 @@ struct HomeView: View { private func content(hubs: [MediaRowGroup]) -> some View { ScrollView { LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) { - let featured = feed.heroItems(for: layout) - if !featured.isEmpty { - HeroCarousel(items: featured) + if ohneServer { + offlinehinweis + } else { + let featured = feed.heroItems(for: layout) + if !featured.isEmpty { + HeroCarousel(items: featured) + } } ForEach(hubs) { hub in MediaRow(row: hub) + // **Ausgegraut, aber nicht gesperrt.** Was hier steht, + // stammt vom letzten Mal; antippen darf man es + // trotzdem — dann kommt ein Satz, der sagt warum, und + // das ist ehrlicher als ein Knopf, der nichts tut. + .opacity(ohneServer && hub.id != HomeFeed.downloadsRow ? 0.4 : 1) } } .padding(.bottom, FlaroSpacing.section) @@ -103,4 +121,36 @@ struct HomeView: View { .scrollIndicators(.never) .flaroRevealsTopChrome() } + + /// Ob gerade kein Server antwortet. + /// + /// Zwei Wege führen hierher: gar nichts geladen, oder ein Abzug vom + /// letzten Mal. Für den Nutzer ist es dieselbe Lage. + private var ohneServer: Bool { + if case .failed = feed.state { return true } + return feed.ausDemSpeicher + } + + /// Statt des Aufmachers, wenn der Server fehlt. + /// + /// **An seiner Stelle, nicht darüber.** Ein grosses Bild vom letzten Mal + /// über einer Startseite, die nicht stimmt, wäre die falsche Betonung — + /// und die Downloads sollen oben stehen. + private var offlinehinweis: some View { + HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.regular) { + Image(systemName: "wifi.slash") + .foregroundStyle(Color.flaroTextMuted) + Text("home.offline") + .font(.flaroCaption) + .foregroundStyle(Color.flaroTextMuted) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + Button("action.retry") { Task { await feed.load(from: session.backend) } } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(FlaroSpacing.regular) + .background(Color.flaroElevated, in: .rect(cornerRadius: 12)) + .padding(.horizontal, FlaroSpacing.screenMargin) + } } diff --git a/Flaro/Views/Navigation/RootView.swift b/Flaro/Views/Navigation/RootView.swift index 0a29af8..5bda5c5 100644 --- a/Flaro/Views/Navigation/RootView.swift +++ b/Flaro/Views/Navigation/RootView.swift @@ -19,6 +19,7 @@ struct RootView: View { @Environment(SubtitleAppearance.self) private var subtitleAppearance @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads + @Environment(Fortschrittspost.self) private var post @Environment(PlaybackDiagnostics.self) private var diagnostics /// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist. @@ -229,6 +230,7 @@ struct RootView: View { backend: session.backend, liveTV: session.liveTV, downloads: downloads, + post: post, diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, diff --git a/Flaro/Views/Player/PlayerModel.swift b/Flaro/Views/Player/PlayerModel.swift index d5a8bde..8b79b09 100644 --- a/Flaro/Views/Player/PlayerModel.swift +++ b/Flaro/Views/Player/PlayerModel.swift @@ -231,6 +231,9 @@ final class PlayerModel { /// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die /// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden. private let downloads: DownloadCoordinator? + /// Siehe `Fortschrittspost`: was der Server nicht annimmt, wird + /// zurückgelegt und nachgereicht. + private let post: Fortschrittspost? private let diagnostics: PlaybackDiagnostics private let policy: PlaybackPolicy private let settings: PlaybackSettings @@ -481,6 +484,8 @@ final class PlayerModel { backend: (any MediaBackend)?, liveTV: (any LiveTVBackend)?, downloads: DownloadCoordinator? = nil, + /// Wohin ein Fortschritt kommt, den der Server nicht annimmt. + post: Fortschrittspost? = nil, diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, @@ -494,6 +499,7 @@ final class PlayerModel { self.backend = backend self.liveTV = liveTV self.downloads = downloads + self.post = post self.diagnostics = diagnostics self.policy = policy self.settings = settings @@ -1781,14 +1787,19 @@ final class PlayerModel { let stelle = currentTime > .zero ? currentTime : request.startTime guard stelle > .zero || state == .started else { return } - await backend.reportProgress(PlaybackReport( + let bericht = PlaybackReport( itemID: item.id, sessionID: playbackSession, versionID: playbackVersionID, state: state, time: stelle, duration: total - )) + ) + // **Was nicht ankommt, wird zurückgelegt.** Ohne das war eine Folge, + // die man offline zu Ende gesehen hat, für den Server nie gelaufen. + if await backend.reportProgress(bericht) == false { + post?.zuruecklegen(bericht, server: backend.identity) + } } // MARK: - Marken und nächste Folge diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift index 6a87e12..787aaf1 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift @@ -450,7 +450,7 @@ public actor FlaroBackend: MediaBackend { method: .delete, path: "/api/v1/stream/\(sessionID)")) } - public func reportProgress(_ report: PlaybackReport) async { + public func reportProgress(_ report: PlaybackReport) async -> Bool { // Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden // Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen // wäre die schlechtere Hälfte des Tauschs. @@ -458,10 +458,15 @@ public actor FlaroBackend: MediaBackend { // Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen // Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden // hiesse, den ersten Bericht wegzuwerfen. - _ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress", - Wire.ProgressRequest( - positionSeconds: report.time.seconds, - runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil))) + do { + try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress", + Wire.ProgressRequest( + positionSeconds: report.time.seconds, + runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil))) + return true + } catch { + return false + } } // MARK: - Zustand melden diff --git a/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift b/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift index 941eab1..72a17b5 100644 --- a/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift +++ b/Packages/FlaroKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift @@ -106,7 +106,7 @@ extension JellyfinBackend { )) } - public func reportProgress(_ report: PlaybackReport) async { + public func reportProgress(_ report: PlaybackReport) async -> Bool { var fields: [String: Any] = [ "ItemId": report.itemID, "PositionTicks": report.time.milliseconds.ticksFromMilliseconds, @@ -126,13 +126,18 @@ extension JellyfinBackend { default: "/Sessions/Playing/Progress" } - guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return } - _ = try? await client.send(JellyfinRequest( - method: .post, - path: path, - body: body, - timeout: .seconds(10) - )) + guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return false } + do { + try await client.send(JellyfinRequest( + method: .post, + path: path, + body: body, + timeout: .seconds(10) + )) + return true + } catch { + return false + } } // MARK: - Hilfen diff --git a/Packages/FlaroKit/Sources/MediaKit/Fortschrittspost.swift b/Packages/FlaroKit/Sources/MediaKit/Fortschrittspost.swift new file mode 100644 index 0000000..2c82eac --- /dev/null +++ b/Packages/FlaroKit/Sources/MediaKit/Fortschrittspost.swift @@ -0,0 +1,126 @@ +import Foundation +import Observation +import OSLog + +/// Fortschritt, der den Server nicht erreicht hat. +/// +/// **Was ohne sie verlorenging.** Eine Folge im Flugzeug zur Hälfte +/// anzusehen, war für den Server ein Vorgang, der nie stattgefunden hat: +/// jede Meldung lief in die tote Leitung, und beim nächsten Verbinden stand +/// der Titel wieder bei null — auf dem Fernseher, im Web, überall. Genau +/// dieser Fall ist der Grund, aus dem man etwas herunterlädt. +/// +/// **Eine Meldung je Titel, die letzte gewinnt.** Wer eine Stunde schaut, +/// erzeugt an die hundert Meldungen; nachzureichen ist davon genau eine +/// sinnvoll — die, an der er aufgehört hat. Alles andere wäre ein Protokoll +/// der Vergangenheit, das der Server erst wieder zusammenrechnen müsste. +/// +/// Auf der Platte, nicht im Speicher: wer die App im Flugmodus beendet, hat +/// sonst umsonst geschaut. +@MainActor +@Observable +public final class Fortschrittspost { + /// Der Briefkasten der App. + /// + /// Einer, wie bei `LibraryCache`: zwei Instanzen auf dieselbe Datei + /// würden einander überschreiben. Der Bauweg mit eigener Ablage bleibt + /// für Prüfungen offen. + public static let shared = Fortschrittspost() + + /// Ein zurückgelegter Stand. + public struct Eintrag: Codable, Sendable, Equatable { + /// Zu welchem Server er gehört. + /// + /// **Sonst ginge er an den falschen.** Wer sich abmeldet und an + /// einem anderen Server anmeldet, bekäme sonst fremde Kennungen + /// mit dem eigenen Sehfortschritt gemeldet. + let server: String + let titelID: String + let sekunden: Double + let laufzeitSekunden: Double? + let zeitpunkt: Date + } + + private let ablage: URL + private let logger = Logger(subsystem: "app.flaro", category: "Fortschrittspost") + private var offen: [String: Eintrag] + + /// Wie viele Stände noch auf einen Server warten. + public var wartend: Int { offen.count } + + public init(ablage: URL? = nil) { + self.ablage = ablage ?? FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: "flaro-fortschritt.json") + self.offen = Self.lesen(from: self.ablage) ?? [:] + } + + /// Legt einen Stand zurück, weil er nicht ankam. + public func zuruecklegen(_ report: PlaybackReport, server: String) { + offen[report.itemID] = Eintrag( + server: server, + titelID: report.itemID, + sekunden: report.time.seconds, + laufzeitSekunden: report.duration > .zero ? report.duration.seconds : nil, + zeitpunkt: Date() + ) + schreiben() + } + + /// Versucht, alles Liegengebliebene loszuwerden. + /// + /// **Was weiterhin scheitert, bleibt liegen.** Ein halb geleerter + /// Briefkasten ist kein Fehler — beim nächsten Versuch geht der Rest. + /// + /// Nacheinander und nicht nebeneinander: es sind selten mehr als eine + /// Handvoll, und zwanzig gleichzeitige Anfragen an einen Server, der + /// gerade erst wieder da ist, wären der falsche erste Gruß. + public func zustellen(über backend: (any Fortschrittsempfaenger)?) async { + guard let backend, !offen.isEmpty else { return } + let kennung = backend.identity + + // **Nicht ewig aufheben.** Ein Stand von vor einem halben Jahr sagt + // nichts mehr darüber, wo jemand heute weiterschauen will — und ein + // Server, der nie wiederkommt, hinterliesse sonst eine Datei, die + // nur wächst. + let frist = Date().addingTimeInterval(-30 * 24 * 60 * 60) + offen = offen.filter { $0.value.zeitpunkt > frist } + + for eintrag in offen.values.sorted(by: { $0.zeitpunkt < $1.zeitpunkt }) + where eintrag.server == kennung { + let angekommen = await backend.reportProgress(PlaybackReport( + itemID: eintrag.titelID, + state: .stopped, + time: .seconds(eintrag.sekunden), + duration: .seconds(eintrag.laufzeitSekunden ?? 0) + )) + guard angekommen else { break } + // **Nur den, der tatsächlich ankam.** Währenddessen kann + // derselbe Titel neu zurückgelegt worden sein; den zu + // entfernen hiesse, den neueren Stand wegzuwerfen. + if offen[eintrag.titelID] == eintrag { offen[eintrag.titelID] = nil } + } + schreiben() + } + + /// Alles vergessen — beim Abmelden. + public func leeren() { + offen = [:] + try? FileManager.default.removeItem(at: ablage) + } + + // MARK: - Platte + + private func schreiben() { + do { + try JSONEncoder().encode(offen).write(to: ablage, options: .atomic) + } catch { + logger.warning("Nicht gespeichert: \(error.localizedDescription, privacy: .public)") + } + } + + private static func lesen(from url: URL) -> [String: Eintrag]? { + guard let daten = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode([String: Eintrag].self, from: daten) + } +} diff --git a/Packages/FlaroKit/Sources/MediaKit/LibraryCache.swift b/Packages/FlaroKit/Sources/MediaKit/LibraryCache.swift index 228a200..d9f63b4 100644 --- a/Packages/FlaroKit/Sources/MediaKit/LibraryCache.swift +++ b/Packages/FlaroKit/Sources/MediaKit/LibraryCache.swift @@ -111,6 +111,41 @@ public actor LibraryCache { } } + /// Die Reihen der Startseite. + /// + /// **Weil es sonst nichts gibt, womit sie sich füllen liesse.** Die + /// Mediathekenliste und die Titel darin liegen schon auf der Platte; die + /// Startseite dagegen entstand bei jedem Aufruf neu am Server, und ohne + /// ihn blieb sie leer. Im Flugzeug ist das der Unterschied zwischen + /// „sehen, was es gibt" und einem Fehlerbildschirm. + public func homeRows(server: String) -> [MediaRowGroup]? { + guard let daten = try? Data(contentsOf: homeRowsFile(server: server)) else { return nil } + return try? JSONDecoder().decode([MediaRowGroup].self, from: daten) + } + + public func storeHomeRows(_ rows: [MediaRowGroup], server: String) { + // Je Reihe eine Bildschirmbreite, mehr sieht ohnehin niemand, bevor + // der Server wieder antwortet. + let gekürzt = rows.map { + MediaRowGroup( + id: $0.id, + title: $0.title, + moreKey: $0.moreKey, + libraryID: $0.libraryID, + items: $0.items.prefix(20).map(\.listEssentials) + ) + } + let ziel = homeRowsFile(server: server) + do { + try FileManager.default.createDirectory( + at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic) + } catch { + logger.warning("Startseite nicht gespeichert: \(error.localizedDescription, privacy: .public)") + } + } + // MARK: - Aufräumen /// Alles zu einem Server vergessen — beim Abmelden. @@ -139,6 +174,10 @@ public actor LibraryCache { directory(server: server).appending(path: "libraries.json") } + private func homeRowsFile(server: String) -> URL { + directory(server: server).appending(path: "home.json") + } + /// Ein Name, der als Datei taugt. /// /// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift index 9942e1a..4d5b5de 100644 --- a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift @@ -6,9 +6,13 @@ import Foundation /// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers — /// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner, /// der alles umfasst, wäre kein Nenner mehr. -public protocol MediaBackend: Sendable { - /// Wie der Server heißt, so wie er sich selbst nennt. - nonisolated var serverName: String { get } +/// Wer Sehfortschritt entgegennimmt. +/// +/// **Ein Ausschnitt aus `MediaBackend`, und zwar genau der, den die +/// `Fortschrittspost` braucht.** Ohne ihn müsste jeder, der das Nachreichen +/// prüfen will, einen vollständigen Server nachbauen — vierzig Methoden für +/// die zwei, die zur Sache gehören. +public protocol Fortschrittsempfaenger: Sendable { /// Wer dieser Server ist — für alles, was ihn über einen Start hinaus /// wiedererkennen muss. /// @@ -17,6 +21,24 @@ public protocol MediaBackend: Sendable { /// 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 } + + /// Meldet, wo man gerade steht. + /// + /// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden + /// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen + /// wäre die schlechtere Hälfte des Tauschs. + /// + /// **Gibt aber zurück, ob sie ankam.** Im Flugzeug geht jede einzelne + /// verloren, und am Ende stünde ein zur Hälfte gesehener Film auf dem + /// Server weiter bei null. Der Aufrufer legt sie dann beiseite und + /// meldet sie nach — siehe `Fortschrittspost`. + @discardableResult + func reportProgress(_ report: PlaybackReport) async -> Bool +} + +public protocol MediaBackend: Sendable, Fortschrittsempfaenger { + /// Wie der Server heißt, so wie er sich selbst nennt. + nonisolated var serverName: String { get } /// Was dieser Server darüber hinaus beherrscht. nonisolated var capabilities: Set { get } @@ -128,13 +150,6 @@ public protocol MediaBackend: Sendable { /// niemand mehr zusieht. func endPlayback(sessionID: String) async - /// Meldet, wo man gerade steht. - /// - /// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden - /// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen - /// wäre die schlechtere Hälfte des Tauschs. - func reportProgress(_ report: PlaybackReport) async - // MARK: - Zustand melden func markWatched(id: String) async throws(MediaBackendError) diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaLibrary.swift b/Packages/FlaroKit/Sources/MediaKit/MediaLibrary.swift index 8702b0a..30e416f 100644 --- a/Packages/FlaroKit/Sources/MediaKit/MediaLibrary.swift +++ b/Packages/FlaroKit/Sources/MediaKit/MediaLibrary.swift @@ -49,7 +49,7 @@ public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable { } /// Eine Reihe auf der Startseite oder in den Suchergebnissen. -public struct MediaRowGroup: Sendable, Equatable, Identifiable { +public struct MediaRowGroup: Sendable, Equatable, Identifiable, Codable { /// Eine stabile Kennung für diese Reihe. /// /// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben diff --git a/Packages/FlaroKit/Tests/MediaKitTests/FortschrittspostTests.swift b/Packages/FlaroKit/Tests/MediaKitTests/FortschrittspostTests.swift new file mode 100644 index 0000000..fdf1dfa --- /dev/null +++ b/Packages/FlaroKit/Tests/MediaKitTests/FortschrittspostTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import MediaKit + +/// Ein Server, der auf Ansage annimmt oder nicht. +private final class Briefkasten: Fortschrittsempfaenger, @unchecked Sendable { + nonisolated let identity: String + /// Ob Meldungen angenommen werden. + var nimmtAn: Bool + private(set) var angekommen: [PlaybackReport] = [] + + init(identity: String = "server-a", nimmtAn: Bool = true) { + self.identity = identity + self.nimmtAn = nimmtAn + } + + func reportProgress(_ report: PlaybackReport) async -> Bool { + guard nimmtAn else { return false } + angekommen.append(report) + return true + } +} + +private func bericht(_ titel: String, sekunden: Double) -> PlaybackReport { + PlaybackReport( + itemID: titel, + state: .stopped, + time: .seconds(sekunden), + duration: .seconds(3600) + ) +} + +@Suite("Nachgereichter Fortschritt") +@MainActor +struct FortschrittspostTests { + /// Eine eigene Datei je Prüfung — sonst sähe die zweite, was die erste + /// liegen gelassen hat. + private func ablage() -> URL { + FileManager.default.temporaryDirectory + .appending(path: "post-\(UUID().uuidString).json") + } + + @Test("Was ankam, wird nicht aufgehoben") + func nichtsLiegenLassen() async { + let post = Fortschrittspost(ablage: ablage()) + let server = Briefkasten() + + post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity) + #expect(post.wartend == 1) + + await post.zustellen(über: server) + #expect(post.wartend == 0) + #expect(server.angekommen.count == 1) + #expect(server.angekommen.first?.itemID == "film") + } + + @Test("Was nicht ankommt, bleibt liegen") + func liegenbleiben() async { + let post = Fortschrittspost(ablage: ablage()) + let server = Briefkasten(nimmtAn: false) + + post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity) + await post.zustellen(über: server) + #expect(post.wartend == 1) + + server.nimmtAn = true + await post.zustellen(über: server) + #expect(post.wartend == 0) + } + + @Test("Je Titel nur der letzte Stand") + func nurDerLetzteStand() async { + let post = Fortschrittspost(ablage: ablage()) + let server = Briefkasten() + + post.zuruecklegen(bericht("film", sekunden: 60), server: server.identity) + post.zuruecklegen(bericht("film", sekunden: 1800), server: server.identity) + #expect(post.wartend == 1) + + await post.zustellen(über: server) + #expect(server.angekommen.count == 1) + #expect(server.angekommen.first?.time == .seconds(1800)) + } + + @Test("Ein fremder Server bekommt nichts") + func nichtAnDenFalschen() async { + let post = Fortschrittspost(ablage: ablage()) + post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a") + + let anderer = Briefkasten(identity: "server-b") + await post.zustellen(über: anderer) + #expect(anderer.angekommen.isEmpty) + #expect(post.wartend == 1) + } + + @Test("Der Briefkasten überlebt den Neustart") + func aufDerPlatte() async { + let datei = ablage() + let erste = Fortschrittspost(ablage: datei) + erste.zuruecklegen(bericht("folge", sekunden: 900), server: "server-a") + + let zweite = Fortschrittspost(ablage: datei) + #expect(zweite.wartend == 1) + + let server = Briefkasten() + await zweite.zustellen(über: server) + #expect(server.angekommen.first?.time == .seconds(900)) + } + + @Test("Leeren wirft alles weg") + func leeren() async { + let post = Fortschrittspost(ablage: ablage()) + post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a") + post.leeren() + #expect(post.wartend == 0) + } +} diff --git a/Packages/FlaroKit/Tests/MediaKitTests/LibraryCacheHomeTests.swift b/Packages/FlaroKit/Tests/MediaKitTests/LibraryCacheHomeTests.swift new file mode 100644 index 0000000..298ceae --- /dev/null +++ b/Packages/FlaroKit/Tests/MediaKitTests/LibraryCacheHomeTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import MediaKit + +@Suite("Die gemerkte Startseite") +struct LibraryCacheHomeTests { + private func speicher() -> LibraryCache { + LibraryCache(root: FileManager.default.temporaryDirectory + .appending(path: "cache-\(UUID().uuidString)", directoryHint: .isDirectory)) + } + + private func titel(_ nummer: Int) -> MediaItem { + MediaItem(id: "t\(nummer)", kind: .movie, title: "Titel \(nummer)", thumb: "/bild/\(nummer)") + } + + @Test("Was gespeichert wurde, kommt zurück") + func hinUndZurueck() async { + let cache = speicher() + let reihen = [ + MediaRowGroup(id: "continue", title: "Weiterschauen", + libraryID: "mediathek", items: [titel(1), titel(2)]), + MediaRowGroup(id: "latest.1", title: "Neu", moreKey: "/mehr", items: [titel(3)]), + ] + + await cache.storeHomeRows(reihen, server: "server-a") + let gemerkt = await cache.homeRows(server: "server-a") + + #expect(gemerkt?.map(\.id) == ["continue", "latest.1"]) + #expect(gemerkt?.first?.title == "Weiterschauen") + #expect(gemerkt?.first?.libraryID == "mediathek") + #expect(gemerkt?.last?.moreKey == "/mehr") + #expect(gemerkt?.first?.items.map(\.id) == ["t1", "t2"]) + // Ohne den Bildpfad wäre der Abzug wertlos: die Plakate liegen + // bereits auf der Platte, nur findet sie dann niemand. + #expect(gemerkt?.first?.items.first?.thumb == "/bild/1") + } + + @Test("Ein anderer Server hat seine eigene Startseite") + func getrenntJeServer() async { + let cache = speicher() + await cache.storeHomeRows( + [MediaRowGroup(id: "continue", title: "Weiterschauen", items: [titel(1)])], + server: "server-a" + ) + let fremd = await cache.homeRows(server: "server-b") + #expect(fremd == nil) + } + + @Test("Ohne gespeicherten Stand kommt nichts") + func nochNichtsDa() async { + let gemerkt = await speicher().homeRows(server: "server-a") + #expect(gemerkt == nil) + } + + @Test("Lange Reihen werden gekürzt") + func nichtAlles() async { + let cache = speicher() + let lang = (1...80).map(titel) + await cache.storeHomeRows( + [MediaRowGroup(id: "latest", title: "Neu", items: lang)], server: "server-a" + ) + let gemerkt = await cache.homeRows(server: "server-a") + #expect(gemerkt?.first?.items.count == 20) + } +}