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 <noreply@anthropic.com>
127 lines
5.0 KiB
Swift
127 lines
5.0 KiB
Swift
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)
|
|
}
|
|
}
|