163 Byte als Film verbucht, in einer Sekunde heruntergeladen

Muss der Server eine Datei umpacken, antwortet er mit **202** und einem
kurzen Text: noch nicht, komm wieder. Die Prüfung im Download verwarf
alles ausserhalb von 200 bis 299 — und 202 liegt mittendrin. Der
Antworttext lag danach als Film auf dem Gerät.

Jetzt ist 202 ein eigener Fall: der Eintrag geht zurück in die
Warteschlange und fragt alle fünfzehn Sekunden erneut, bis zu fünf
Minuten lang. Es ist nichts schiefgegangen, es ist nur noch nicht fertig
— und im Sekundentakt nachzufragen hiesse, dieselbe Datei hundertmal
anzufangen und wegzuwerfen.

Gezählt wird im Arbeitsspeicher, nicht im Eintrag: nach einem Neustart
beginnt das Zählen von vorn, und das ist richtig — der Server hat
inzwischen vermutlich fertig gepackt.

Dazu, aus derselben Meldung: bei 21∶9 auf einem Telefon reichte das Bild
hinter die Dynamic Island. Im Vollbild bleibt das so — wer es wählt, hat
sich fürs Beschneiden entschieden. Im normalen Modus nicht mehr: dort ist
der Anspruch gerade, dass nichts fehlt, und dann darf auch nichts
verdeckt sein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-11 20:33:32 +02:00
co-authored by Claude Opus 5
parent c05004f586
commit a44d55e71c
3 changed files with 120 additions and 18 deletions
+24 -1
View File
@@ -106,8 +106,18 @@ struct PlayerView: View {
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
// der Hintergrund dahinter, und wer ihn anklickt, klickt
// nicht aufs Bild — das muss niemand mehr feststellen.
// **Im Vollbild über alles, sonst nicht unter die
// Insel.**
//
// Bei 21∶9 auf einem Telefon reicht das Bild über die
// ganze Breite — und damit hinter die Dynamic Island, die
// links einen Streifen davon verdeckt. Wer Vollbild
// wählt, hat sich fürs Beschneiden entschieden; dort ist
// Rand an Rand folgerichtig. Im normalen Modus ist der
// Anspruch gerade, dass nichts fehlt — dann darf auch
// nichts verdeckt sein. Plex macht es genauso.
Color.clear
.ignoresSafeArea()
.modifier(Randlos(an: model.videoFit == .fill))
.overlay {
surface(for: model)
.contentShape(.rect)
@@ -1049,3 +1059,16 @@ extension Duration {
return String(format: "%d:%02d", minutes, seconds)
}
}
/// Lässt eine Fläche über die Sicherheitsränder hinausreichen — oder nicht.
///
/// **Ein eigener Modifikator, weil `ignoresSafeArea` keinen Schalter hat.**
/// Ein `if` im Baum wäre zwei verschiedene Ansichten, und SwiftUI baut die
/// beim Umschalten neu auf — die Wiedergabe begänne von vorn.
private struct Randlos: ViewModifier {
let an: Bool
func body(content: Content) -> some View {
if an { content.ignoresSafeArea() } else { content }
}
}
@@ -458,6 +458,13 @@ public final class DownloadCoordinator {
}
}
/// Wie oft je Eintrag schon auf ein Umpacken gewartet wurde.
///
/// **Im Arbeitsspeicher und nicht im Eintrag.** Nach einem Neustart
/// beginnt das Zählen von vorn — und das ist richtig: der Server hat
/// inzwischen vermutlich fertig gepackt.
private var wartend: [String: Int] = [:]
private func start(_ item: DownloadItem) {
guard let url = URL(string: item.sourceURLString) else {
fail(item, message: "Ungültige Adresse")
@@ -526,6 +533,7 @@ public final class DownloadCoordinator {
}
func handleCompletion(ratingKey: String, temporaryFile: URL) {
wartend[ratingKey] = nil
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
let sourceURL = URL(string: item.sourceURLString)
else { return }
@@ -608,6 +616,45 @@ public final class DownloadCoordinator {
}
/// Der Server hat die Anfrage abgelehnt.
/// Wie oft auf ein Umpacken gewartet wird, bevor aufgegeben wird.
///
/// **Grosszügig, und das mit Absicht.** Ein Dreistünder in 4K braucht auf
/// einem NAS Minuten; abzubrechen, während die Arbeit läuft, wäre die
/// eine Antwort, die niemandem hilft. Zwanzig Versuche im Abstand von
/// fünfzehn Sekunden sind fünf Minuten.
private static let hoechstensWarten = 20
private static let wartezeit = Duration.seconds(15)
/// Der Server packt noch um — später noch einmal fragen.
///
/// **Nicht sofort.** Ein Umpacken dauert Minuten, und im Sekundentakt
/// nachzufragen hiesse, dieselbe Datei hundertmal anzufangen und wieder
/// wegzuwerfen.
func handleNotReady(ratingKey: String) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
let versuche = (wartend[ratingKey] ?? 0) + 1
wartend[ratingKey] = versuche
guard versuche <= Self.hoechstensWarten else {
wartend[ratingKey] = nil
fail(item, message: String(localized: "downloads.failed.notReady", bundle: .module))
Task { await startNextIfPossible() }
return
}
// **Zurück in die Warteschlange, nicht in den Fehler.** Es ist
// nichts schiefgegangen; es ist nur noch nicht fertig.
item.state = .waiting
save()
Task { [weak self] in
try? await Task.sleep(for: Self.wartezeit)
guard let self else { return }
await self.resume(item)
}
}
func handleServerRejection(ratingKey: String, status: Int) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
@@ -740,6 +787,21 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch
) {
guard let key = downloadTask.taskDescription else { return }
// **202 heisst: noch nicht, komm wieder.**
//
// Der Server packt eine Datei um, die so nicht ausgeliefert werden
// kann, und antwortet währenddessen mit 202 und einem kurzen Text.
// Das ist ein Erfolgscode, also ging er durch die Prüfung darunter
// glatt hindurch: 163 Byte Antworttext lagen danach als Film auf dem
// Gerät, „in einer Sekunde heruntergeladen".
if let response = downloadTask.response as? HTTPURLResponse,
response.statusCode == 202 {
Task { @MainActor [weak coordinator] in
coordinator?.handleNotReady(ratingKey: key)
}
return
}
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
@@ -1,6 +1,40 @@
{
"sourceLanguage": "en",
"strings": {
"downloads.failed.notReady": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server packt diese Datei noch um. Versuch es in ein paar Minuten noch einmal."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server is still repackaging this file. Try again in a few minutes."
}
}
}
},
"downloads.failed.status %lld": {
"comment": "Der Server hat die Download-Anfrage abgelehnt",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server refused the download with error %lld."
}
}
}
},
"downloads.failed.tooSmall %@": {
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
"localizations": {
@@ -34,23 +68,6 @@
}
}
}
},
"downloads.failed.status %lld": {
"comment": "Der Server hat die Download-Anfrage abgelehnt",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server refused the download with error %lld."
}
}
}
}
},
"version": "1.0"