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