Dein Bericht: bei rund 3 GB abgebrochen, beim Fortsetzen wieder bei null,
und später „plötzlich bei 60 Prozent fertig". Beide Hälften standen so im
Code.
**Fertig war sie nicht, sie war halb.**
if expected > 0, Double(written) < Double(expected) * 0.5 { verwerfen }
Alles über der Hälfte galt als vollständig. Diese Regel war nie für diesen
Fall gedacht, sondern für die **hochgerechnete** Größe der Transcoder-Stufen
(Bitrate × Dauer) — dort ist Abweichung normal. Beim Original nennt der
Server die Dateigröße in den Metadaten, und da ist jedes fehlende Byte ein
Abriss.
Das unterscheidet jetzt `expectedIsExact`, gesetzt beim Planen und
mitgeführt am Eintrag. Dazu merkt sich der Koordinator, für welche Titel der
Server eine `Content-Length` geschickt hat — auch das ist eine Zusage.
**„Fortsetzen" war eine Zusage, die der Code nicht halten konnte.**
`NSURLSessionDownloadTaskResumeData` kam im ganzen Projekt nicht vor; den
Wiederaufnahme-Datensatz gab es nur beim Pausieren von Hand. Nach einem
Netzfehler war er weg, und der Knopf daneben hieß trotzdem „Fortsetzen".
Jetzt wird er aus dem Fehler gerettet, und wo es ihn nicht gibt, heißt der
Knopf „Erneut versuchen".
**Und es merkte niemand, wenn das WLAN zurückkam.** `RootView` reicht das
jetzt weiter — nur für Einträge mit Wiederaufnahme-Datensatz, damit ein 404
sich nicht im Kreis versucht.
**Rate und Restzeit** stehen in der Zeile: „12 MB / 20 MB · 3,4 MB/s ·
gleich fertig". `DownloadRate` ist ein reiner Wertetyp mit Uhr von außen —
geglättet, weil rohe Differenzen um ein Vielfaches springen, und ohne Zahl,
solange nichts gemessen ist. Neun Tests.
**Zwei Dinge, die erst die Messung gezeigt hat** — und die beide nicht in
der App liegen:
Erstens: reißt eine Übertragung ab, **die eine `Content-Length` angekündigt
hatte**, wiederholt die Hintergrundsitzung von iOS sie selbst — stumm, ohne
den Delegaten zu fragen, und **jedes Mal ab Byte null**. Gemessen: sieben
Anläufe, jeder die vollen 12 MB. Das ist genau dein „fing wieder bei null
an", und dagegen hilft kein Wiederaufnahme-Datensatz.
Zweitens: ein Fortsetzen braucht eine Prüfmarke vom Server. Ohne `ETag`
oder `Last-Modified` kann iOS sein `If-Range` nicht stellen und lädt alles
noch einmal — gemessen, 20 MB zweimal. Mit Marke kam die zweite Anfrage als
`Range: bytes=5636096-` und holte nur den Rest.
Die Attrappe kann das jetzt alles nachstellen: eine beliebig große Datei,
die auf Wunsch mittendrin abreißt, mit oder ohne angekündigte Länge, und
gedrosselt. Ohne sie ist nichts davon beweisbar — und genau daran ist der
Fehler vorbeigekommen.
Im Simulator gegen die Attrappe: 12 von 20 MB → „Fehlgeschlagen:
Übertragung unvollständig", nicht Haken. Vollständig → Haken. iOS und tvOS
bauen, 489 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
6.5 KiB
Swift
175 lines
6.5 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// Ein heruntergeladener oder herunterzuladender Titel.
|
|
///
|
|
/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im
|
|
/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start
|
|
/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist.
|
|
@Model
|
|
public final class DownloadItem {
|
|
/// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel
|
|
/// soll nicht zweimal in der Warteschlange stehen.
|
|
@Attribute(.unique) public var ratingKey: String
|
|
|
|
/// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern,
|
|
/// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und
|
|
/// ein unlesbarer Zustand hieße: laufende Downloads sind verloren.
|
|
public var stateRaw: String
|
|
|
|
public var title: String
|
|
public var subtitle: String?
|
|
/// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die
|
|
/// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline".
|
|
public var seriesKey: String?
|
|
public var seriesTitle: String?
|
|
public var seasonKey: String?
|
|
public var seasonIndex: Int?
|
|
public var episodeIndex: Int?
|
|
|
|
public var totalBytes: Int64
|
|
public var receivedBytes: Int64
|
|
/// Ob `totalBytes` die **genaue** Größe ist oder eine Schätzung.
|
|
///
|
|
/// **Daran hängt, wann eine Datei als abgerissen gilt.** Beim Original
|
|
/// nennt der Server die Dateigröße in den Metadaten — da ist jedes
|
|
/// fehlende Byte ein Abriss. Bei den umgerechneten Stufen rechnet die App
|
|
/// die Größe aus Bitrate und Dauer hoch, und die weicht von Haus aus ab;
|
|
/// dort wäre dieselbe Strenge falsch.
|
|
///
|
|
/// Ohne diese Unterscheidung galt für beides die Hälfte-Regel — und eine
|
|
/// bei sechzig Prozent abgerissene Datei bekam einen Haken.
|
|
public var expectedIsExact: Bool = false
|
|
|
|
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
|
|
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
|
|
/// absoluter Pfad zeigt danach ins Leere.
|
|
public var localFileName: String?
|
|
public var artworkFileName: String?
|
|
|
|
public var addedAt: Date
|
|
public var completedAt: Date?
|
|
/// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen.
|
|
public var watchedAt: Date?
|
|
public var failureMessage: String?
|
|
|
|
/// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten
|
|
/// App-Container und nicht in den Voreinstellungen.
|
|
public var sourceURLString: String
|
|
public var durationMilliseconds: Int?
|
|
|
|
/// Was der Player offline über die Datei wissen muss: Container, Codecs,
|
|
/// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und
|
|
/// sich eigene SwiftData-Modelle dafür nicht lohnen.
|
|
public var offlineMediaData: Data?
|
|
|
|
/// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim
|
|
/// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null.
|
|
public var resumeData: Data?
|
|
|
|
public init(
|
|
ratingKey: String,
|
|
title: String,
|
|
subtitle: String? = nil,
|
|
sourceURLString: String,
|
|
state: DownloadState = .queued,
|
|
totalBytes: Int64 = 0,
|
|
seriesKey: String? = nil,
|
|
seriesTitle: String? = nil,
|
|
seasonKey: String? = nil,
|
|
seasonIndex: Int? = nil,
|
|
episodeIndex: Int? = nil,
|
|
durationMilliseconds: Int? = nil,
|
|
offlineMediaData: Data? = nil,
|
|
expectedIsExact: Bool = false
|
|
) {
|
|
self.ratingKey = ratingKey
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.sourceURLString = sourceURLString
|
|
self.stateRaw = state.rawValue
|
|
self.totalBytes = totalBytes
|
|
self.receivedBytes = 0
|
|
self.seriesKey = seriesKey
|
|
self.seriesTitle = seriesTitle
|
|
self.seasonKey = seasonKey
|
|
self.seasonIndex = seasonIndex
|
|
self.episodeIndex = episodeIndex
|
|
self.durationMilliseconds = durationMilliseconds
|
|
self.offlineMediaData = offlineMediaData
|
|
self.expectedIsExact = expectedIsExact
|
|
self.addedAt = Date()
|
|
}
|
|
|
|
public var state: DownloadState {
|
|
get { DownloadState(rawValue: stateRaw) ?? .failed }
|
|
set { stateRaw = newValue.rawValue }
|
|
}
|
|
|
|
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
|
|
///
|
|
/// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein
|
|
/// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt
|
|
/// die ehrlichere Anzeige.
|
|
public var progress: Double? {
|
|
guard totalBytes > 0 else { return nil }
|
|
return min(Double(receivedBytes) / Double(totalBytes), 1)
|
|
}
|
|
|
|
public var isComplete: Bool { state == .completed }
|
|
|
|
/// Wie viel Platz dieser Eintrag gerade belegt.
|
|
public var occupiedBytes: Int64 {
|
|
state.occupiesStorage ? max(receivedBytes, 0) : 0
|
|
}
|
|
}
|
|
|
|
/// Was der Player offline über eine Datei wissen muss.
|
|
///
|
|
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
|
|
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
|
|
/// um zwischen die Engines zu wählen.
|
|
public struct OfflineMedia: Sendable, Codable, Equatable {
|
|
public var container: String?
|
|
public var videoCodec: String?
|
|
public var audioCodec: String?
|
|
public var videoResolution: String?
|
|
public var bitrate: Int?
|
|
public var audioLanguages: [String]
|
|
public var subtitleLanguages: [String]
|
|
/// Intro und Abspann, in Millisekunden.
|
|
public var markers: [OfflineMarker]
|
|
|
|
public init(
|
|
container: String? = nil,
|
|
videoCodec: String? = nil,
|
|
audioCodec: String? = nil,
|
|
videoResolution: String? = nil,
|
|
bitrate: Int? = nil,
|
|
audioLanguages: [String] = [],
|
|
subtitleLanguages: [String] = [],
|
|
markers: [OfflineMarker] = []
|
|
) {
|
|
self.container = container
|
|
self.videoCodec = videoCodec
|
|
self.audioCodec = audioCodec
|
|
self.videoResolution = videoResolution
|
|
self.bitrate = bitrate
|
|
self.audioLanguages = audioLanguages
|
|
self.subtitleLanguages = subtitleLanguages
|
|
self.markers = markers
|
|
}
|
|
}
|
|
|
|
public struct OfflineMarker: Sendable, Codable, Equatable {
|
|
public var type: String
|
|
public var startMilliseconds: Int
|
|
public var endMilliseconds: Int
|
|
|
|
public init(type: String, startMilliseconds: Int, endMilliseconds: Int) {
|
|
self.type = type
|
|
self.startMilliseconds = startMilliseconds
|
|
self.endMilliseconds = endMilliseconds
|
|
}
|
|
}
|