Ohne Server stand man vor der Tür, mit dem Film in der Tasche

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>
This commit is contained in:
Scarriffle
2026-10-12 19:41:22 +02:00
co-authored by Claude Opus 5
parent 77593105de
commit 8f0568cb6c
17 changed files with 613 additions and 39 deletions
@@ -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
@@ -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
@@ -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)
}
}
@@ -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
@@ -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<BackendCapability> { 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)
@@ -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