Vier Punkte: Player-Hänger, kaputte Downloads, Stufenliste, Kopfleiste
Player hing sich auf Die Qualitätsauswahl war ein Menu. Schloss man es durch einen Tipp ins Bild, blieb eine unsichtbare Ebene zurück, die alle weiteren Tipps schluckte — die Bedienelemente kamen nicht mehr, pausieren ging nicht mehr. Jetzt ein confirmationDialog, der sich selbst wegräumt. Nach dem Schließen werden die Bedienelemente eingeblendet und die Ausblendfrist neu gestartet. Downloads galten als fertig, waren aber leer Zwei Fehler übereinander. Erstens gilt ein URLSessionDownloadTask auch bei 404 oder 500 als erfolgreich und legt die Fehlerseite als Datei ab. Zweitens überschrieb ich danach die erwartete Größe mit der empfangenen — womit die Anzeige auf hundert Prozent stand und der Beweis weg war. 873 MB waren in einer Sekunde „fertig", und beim Abspielen kam nur „Wiedergabe wurde unterbrochen". Jetzt wird der Statuscode geprüft, die erwartete Größe bleibt stehen, und eine unplausible Datei wird verworfen statt verbucht: unter 100 KB ist keine Videodatei, unter der Hälfte der angekündigten Größe ist eine abgebrochene Übertragung. Sechs Tests dazu. Stufenliste ergab keinen Sinn Für einen 720p-Inhalt standen Original, Full HD und HD mit derselben geschätzten Größe untereinander — das sah aus, als wäre die Schätzung kaputt. Sie war es nicht, die Stufen waren es: hochrechnen bringt nur eine größere Datei. Angeboten wird jetzt nur noch, was unter der Auflösung der Datei liegt, und beim Original steht dabei, was es ist: „Original · 720p · 873 MB". Kopfleiste Kein Hintergrund mehr statt eines weichgezeichneten — die Knöpfe bringen ihre eigene Glasfläche mit. Der Titel ist weg: der Name der App gehört auf den Homescreen, nicht über jeden Bildschirm. 145 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -468,9 +468,22 @@ public final class DownloadCoordinator {
|
||||
let name = fileStore.fileName(forRatingKey: ratingKey, sourceURL: sourceURL)
|
||||
do {
|
||||
_ = try fileStore.adopt(temporaryFile: temporaryFile, as: name)
|
||||
let written = fileStore.size(ofFileName: name)
|
||||
|
||||
// Die erwartete Größe NICHT überschreiben. Genau das hat den
|
||||
// Fehler verdeckt: kamen nur ein paar Kilobyte an, wurden sie als
|
||||
// empfangen *und* als erwartet notiert — und die Anzeige stand auf
|
||||
// hundert Prozent.
|
||||
if let problem = Self.implausibility(written: written, expected: item.totalBytes) {
|
||||
fileStore.remove(fileName: name)
|
||||
fail(item, message: problem)
|
||||
Task { await startNextIfPossible() }
|
||||
return
|
||||
}
|
||||
|
||||
item.localFileName = name
|
||||
item.receivedBytes = fileStore.size(ofFileName: name)
|
||||
item.totalBytes = item.receivedBytes
|
||||
item.receivedBytes = written
|
||||
if item.totalBytes <= 0 { item.totalBytes = written }
|
||||
item.state = .completed
|
||||
item.completedAt = Date()
|
||||
save()
|
||||
@@ -483,6 +496,40 @@ public final class DownloadCoordinator {
|
||||
Task { await startNextIfPossible() }
|
||||
}
|
||||
|
||||
/// Prüft, ob die fertige Datei plausibel ist.
|
||||
///
|
||||
/// Gibt eine Begründung zurück, wenn nicht. Zwei Fälle fallen darunter:
|
||||
/// eine winzige Datei (fast immer eine Fehlerseite) und eine, die deutlich
|
||||
/// unter der angekündigten Größe liegt (abgebrochene Übertragung, die als
|
||||
/// erfolgreich gemeldet wurde).
|
||||
static func implausibility(written: Int64, expected: Int64) -> String? {
|
||||
// Unter hundert Kilobyte ist keine Videodatei. Dieser Wert liegt
|
||||
// bewusst niedrig: eine sehr kurze Folge kann klein sein, eine
|
||||
// HTML-Fehlerseite ist es immer.
|
||||
if written < 100_000 {
|
||||
return String(
|
||||
localized: "downloads.failed.tooSmall \(written.formatted(.byteCount(style: .file)))",
|
||||
bundle: .module
|
||||
)
|
||||
}
|
||||
// Zehn Prozent Abweichung nach unten sind noch erklärbar — der
|
||||
// Transcoder kennt die Endgröße vorab nicht genau. Die Hälfte nicht.
|
||||
if expected > 0, Double(written) < Double(expected) * 0.5 {
|
||||
return String(
|
||||
localized: "downloads.failed.truncated \(written.formatted(.byteCount(style: .file))) \(expected.formatted(.byteCount(style: .file)))",
|
||||
bundle: .module
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Der Server hat die Anfrage abgelehnt.
|
||||
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))
|
||||
Task { await startNextIfPossible() }
|
||||
}
|
||||
|
||||
func handleFailure(ratingKey: String, error: any Error) {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
|
||||
@@ -568,6 +615,19 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch
|
||||
) {
|
||||
guard let key = downloadTask.taskDescription else { 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
|
||||
// Sekunde „heruntergeladen".
|
||||
if let response = downloadTask.response as? HTTPURLResponse,
|
||||
!(200..<300).contains(response.statusCode) {
|
||||
let status = response.statusCode
|
||||
Task { @MainActor [weak coordinator] in
|
||||
coordinator?.handleServerRejection(ratingKey: key, status: status)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Die temporäre Datei verschwindet, sobald dieser Aufruf zurückkehrt.
|
||||
// Sie muss deshalb hier und jetzt in Sicherheit gebracht werden, nicht
|
||||
// erst nach einem Sprung auf den Hauptakteur.
|
||||
|
||||
Reference in New Issue
Block a user