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) + } +}