Ein Download riss ab, galt als fertig — und setzte nie fort
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c37087fd34
commit
e86c0a6e95
+4930
-4876
File diff suppressed because it is too large
Load Diff
@@ -139,6 +139,11 @@ struct DownloadService {
|
|||||||
durationMilliseconds: item.duration,
|
durationMilliseconds: item.duration,
|
||||||
originalBytes: part.size ?? 0
|
originalBytes: part.size ?? 0
|
||||||
),
|
),
|
||||||
|
// **Nur beim Original ist die Größe eine Tatsache.** Sie steht in
|
||||||
|
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
|
||||||
|
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
|
||||||
|
// darf abweichen, ohne dass die Datei kaputt wäre.
|
||||||
|
expectedIsExact: quality == .original && part.size != nil,
|
||||||
offlineMedia: offlineMedia(for: item, media: media, part: part)
|
offlineMedia: offlineMedia(for: item, media: media, part: part)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,14 +222,21 @@ private struct DownloadRow: View {
|
|||||||
.accessibilityLabel(Text("downloads.pause"))
|
.accessibilityLabel(Text("downloads.pause"))
|
||||||
|
|
||||||
case .paused, .failed:
|
case .paused, .failed:
|
||||||
|
// **Fortsetzen und erneut versuchen sind nicht dasselbe**, und
|
||||||
|
// der Knopf hat beides gleich genannt. „Fortsetzen" stand auch
|
||||||
|
// dort, wo es nichts fortzusetzen gab — eine Zusage, die der Code
|
||||||
|
// nicht halten konnte: ein gescheiterter Download fing wieder bei
|
||||||
|
// null an. Jetzt entscheidet der Wiederaufnahme-Datensatz, und er
|
||||||
|
// entsteht genau dann, wenn eine Übertragung unterbrochen wurde.
|
||||||
Button {
|
Button {
|
||||||
Task { await downloads.resume(item) }
|
Task { await downloads.resume(item) }
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.clockwise.circle").font(.title3)
|
Image(systemName: continues ? "play.circle" : "arrow.clockwise.circle")
|
||||||
|
.font(.title3)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
.accessibilityLabel(Text("downloads.resume"))
|
.accessibilityLabel(Text(continues ? "downloads.resume" : "action.retry"))
|
||||||
|
|
||||||
case .completed:
|
case .completed:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
@@ -260,12 +267,51 @@ private struct DownloadRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob ein Antippen dort weitermacht, wo es aufgehört hat.
|
||||||
|
///
|
||||||
|
/// Ein angehaltener Download hat seinen Wiederaufnahme-Datensatz immer;
|
||||||
|
/// ein gescheiterter nur, wenn ihn das Netz unterbrochen hat und nicht der
|
||||||
|
/// Server abgelehnt.
|
||||||
|
private var continues: Bool {
|
||||||
|
item.state == .paused || item.resumeData != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie schnell es gerade geht — `nil`, solange nichts gemessen ist.
|
||||||
|
private var speed: String? {
|
||||||
|
guard let perSecond = downloads.rate(forRatingKey: item.ratingKey)?.bytesPerSecond,
|
||||||
|
perSecond > 0
|
||||||
|
else { return nil }
|
||||||
|
let menge = Int64(perSecond).formatted(.byteCount(style: .file))
|
||||||
|
return String(localized: "downloads.perSecond \(menge)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie lange es bei diesem Tempo noch dauert.
|
||||||
|
private var remaining: String? {
|
||||||
|
guard let rate = downloads.rate(forRatingKey: item.ratingKey),
|
||||||
|
let rest = rate.remaining(received: item.receivedBytes, total: item.totalBytes)
|
||||||
|
else { return nil }
|
||||||
|
// Unter einer Minute keine Zahl: „noch 0 Min" wäre falsch und „noch
|
||||||
|
// 43 Sek" genauer, als die Schätzung ist.
|
||||||
|
guard rest >= .seconds(60) else {
|
||||||
|
return String(localized: "downloads.remaining.underMinute")
|
||||||
|
}
|
||||||
|
return String(localized: "downloads.remaining \(rest.runtimeLabel)")
|
||||||
|
}
|
||||||
|
|
||||||
private var statusLine: String {
|
private var statusLine: String {
|
||||||
switch item.state {
|
switch item.state {
|
||||||
case .downloading where item.totalBytes > 0:
|
case .downloading where item.totalBytes > 0:
|
||||||
"\(item.receivedBytes.formatted(.byteCount(style: .file))) / \(item.totalBytes.formatted(.byteCount(style: .file)))"
|
[
|
||||||
|
"\(item.receivedBytes.formatted(.byteCount(style: .file))) / \(item.totalBytes.formatted(.byteCount(style: .file)))",
|
||||||
|
speed,
|
||||||
|
remaining,
|
||||||
|
].compactMap(\.self).joined(separator: " · ")
|
||||||
case .downloading:
|
case .downloading:
|
||||||
item.receivedBytes.formatted(.byteCount(style: .file))
|
// Ohne bekannte Gesamtgröße gibt es keine Restzeit — das Tempo
|
||||||
|
// aber schon, und es ist genau dort die einzige Auskunft darüber,
|
||||||
|
// ob überhaupt etwas durchgeht.
|
||||||
|
[item.receivedBytes.formatted(.byteCount(style: .file)), speed]
|
||||||
|
.compactMap(\.self).joined(separator: " · ")
|
||||||
case .paused:
|
case .paused:
|
||||||
String(localized: "downloads.paused")
|
String(localized: "downloads.paused")
|
||||||
case .queued, .waiting:
|
case .queued, .waiting:
|
||||||
|
|||||||
@@ -181,6 +181,19 @@ struct RootView: View {
|
|||||||
Task { await handOver(to: request) }
|
Task { await handOver(to: request) }
|
||||||
}
|
}
|
||||||
.task { await session.restore() }
|
.task { await session.restore() }
|
||||||
|
// **Kommt das Netz zurück, geht es weiter.**
|
||||||
|
//
|
||||||
|
// Ein Download, den ein Funkloch unterbrochen hat, lag bisher da, bis
|
||||||
|
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
|
||||||
|
// Stunde wieder stand.
|
||||||
|
//
|
||||||
|
// Hier und nicht im Koordinator: `FlixyDownloads` hängt bewusst nicht
|
||||||
|
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
|
||||||
|
// (siehe `TransferHold`). Diese Ansicht kennt beide.
|
||||||
|
.onChange(of: network.isReachable) { _, erreichbar in
|
||||||
|
guard erreichbar else { return }
|
||||||
|
Task { await downloads.resumeInterrupted() }
|
||||||
|
}
|
||||||
.onChange(of: session.phase) { _, newPhase in
|
.onChange(of: session.phase) { _, newPhase in
|
||||||
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
|
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
|
||||||
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wie schnell ein Download gerade läuft — und wie lange er noch braucht.
|
||||||
|
///
|
||||||
|
/// **Ein Wertetyp mit Uhr von außen.** Die Rückmeldungen der Übertragung
|
||||||
|
/// kommen mehrmals je Sekunde; was daraus eine Zahl macht, ist reine Rechnung
|
||||||
|
/// und gehört nicht in den Koordinator. Die Zeitpunkte kommen als Argument
|
||||||
|
/// herein, damit sich eine Übertragung ohne echte Uhr nachstellen lässt — eine
|
||||||
|
/// Rate, die man nur im Betrieb beurteilen kann, ist keine prüfbare Zahl.
|
||||||
|
///
|
||||||
|
/// **Warum geglättet.** Zwischen zwei Rückmeldungen schwankt die rohe
|
||||||
|
/// Differenz um ein Vielfaches: ein Puffer läuft voll, der Funk hat ein Loch,
|
||||||
|
/// das System hält die Hintergrundsitzung kurz an. Ungeglättet stünde dort
|
||||||
|
/// eine Zahl, die zehnmal je Sekunde springt — und eine springende Zahl ist
|
||||||
|
/// keine Auskunft, sondern Unruhe.
|
||||||
|
public struct DownloadRate: Sendable, Equatable {
|
||||||
|
/// Wie stark ein neuer Messwert gegenüber dem bisherigen zählt.
|
||||||
|
///
|
||||||
|
/// Ein Drittel: schnell genug, dass ein echter Einbruch binnen weniger
|
||||||
|
/// Sekunden ankommt, träge genug, dass einzelne Ausreißer untergehen.
|
||||||
|
private static let weight = 1.0 / 3.0
|
||||||
|
|
||||||
|
/// Kürzere Abstände werden nicht gemessen.
|
||||||
|
///
|
||||||
|
/// Über fünfzig Millisekunden gerechnet entscheidet der Zufall, wann das
|
||||||
|
/// System den Puffer leert — nicht die Leitung.
|
||||||
|
static let minimumInterval: Duration = .milliseconds(500)
|
||||||
|
|
||||||
|
/// Ab dieser Lücke beginnt die Messung von vorn.
|
||||||
|
///
|
||||||
|
/// So lange ohne eine einzige Rückmeldung heißt nicht „langsam", sondern
|
||||||
|
/// „stand still": die App war angehalten, das Gerät gesperrt, das Netz
|
||||||
|
/// weg. Diese Pause in die Rate einzurechnen ergäbe eine Zahl nahe null
|
||||||
|
/// und eine Restzeit von Stunden.
|
||||||
|
static let maximumInterval: Duration = .seconds(10)
|
||||||
|
|
||||||
|
/// Ein Messpunkt: wie viel war wann da.
|
||||||
|
///
|
||||||
|
/// Als Typ und nicht als Tupel — ein Tupel kann `Equatable` nicht erben,
|
||||||
|
/// und ohne das wäre der ganze Wertetyp nicht vergleichbar.
|
||||||
|
private struct Anchor: Equatable {
|
||||||
|
var bytes: Int64
|
||||||
|
var at: ContinuousClock.Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der letzte Messpunkt, gegen den gerechnet wird.
|
||||||
|
private var anchor: Anchor?
|
||||||
|
private var smoothed: Double?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Bytes je Sekunde — `nil`, solange nichts gemessen wurde.
|
||||||
|
///
|
||||||
|
/// **Nicht null.** Null wäre eine Auskunft („es geht nichts durch"), und
|
||||||
|
/// die stimmt in der ersten halben Sekunde nie.
|
||||||
|
public var bytesPerSecond: Double? { smoothed }
|
||||||
|
|
||||||
|
/// Nimmt den Stand der Übertragung entgegen.
|
||||||
|
public mutating func record(bytes: Int64, at now: ContinuousClock.Instant) {
|
||||||
|
guard let anchor else {
|
||||||
|
self.anchor = Anchor(bytes: bytes, at: now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = now - anchor.at
|
||||||
|
guard elapsed >= Self.minimumInterval else { return }
|
||||||
|
defer { self.anchor = Anchor(bytes: bytes, at: now) }
|
||||||
|
|
||||||
|
// Rückwärts heißt: eine neue Aufgabe fängt von vorn an. Dann ist jede
|
||||||
|
// bisherige Messung ungültig — und eine negative Rate wäre Unsinn.
|
||||||
|
guard bytes >= anchor.bytes, elapsed <= Self.maximumInterval else {
|
||||||
|
smoothed = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let seconds = elapsed.inSeconds
|
||||||
|
guard seconds > 0 else { return }
|
||||||
|
|
||||||
|
let gemessen = Double(bytes - anchor.bytes) / seconds
|
||||||
|
smoothed = smoothed.map { $0 + (gemessen - $0) * Self.weight } ?? gemessen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie lange es bei dieser Rate noch dauert.
|
||||||
|
///
|
||||||
|
/// `nil`, solange keine Rate feststeht oder die Gesamtgröße unbekannt ist.
|
||||||
|
/// Eine Restzeit ohne Rate wäre geraten, und geraten sieht man ihr nicht
|
||||||
|
/// an.
|
||||||
|
public func remaining(received: Int64, total: Int64) -> Duration? {
|
||||||
|
guard let smoothed, smoothed > 0, total > received else { return nil }
|
||||||
|
return .seconds(Double(total - received) / smoothed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Duration {
|
||||||
|
/// Die Dauer als Sekunden.
|
||||||
|
///
|
||||||
|
/// `components` liefert ganze Sekunden und Attosekunden getrennt; für eine
|
||||||
|
/// Division braucht es beides in einer Zahl.
|
||||||
|
var inSeconds: Double {
|
||||||
|
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ public struct DownloadPlan: Sendable {
|
|||||||
public var episodeIndex: Int?
|
public var episodeIndex: Int?
|
||||||
public var durationMilliseconds: Int?
|
public var durationMilliseconds: Int?
|
||||||
public var expectedBytes: Int64
|
public var expectedBytes: Int64
|
||||||
|
/// Ob `expectedBytes` die genaue Größe ist — siehe `DownloadItem`.
|
||||||
|
public var expectedIsExact: Bool
|
||||||
public var offlineMedia: OfflineMedia?
|
public var offlineMedia: OfflineMedia?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -30,6 +32,7 @@ public struct DownloadPlan: Sendable {
|
|||||||
episodeIndex: Int? = nil,
|
episodeIndex: Int? = nil,
|
||||||
durationMilliseconds: Int? = nil,
|
durationMilliseconds: Int? = nil,
|
||||||
expectedBytes: Int64 = 0,
|
expectedBytes: Int64 = 0,
|
||||||
|
expectedIsExact: Bool = false,
|
||||||
offlineMedia: OfflineMedia? = nil
|
offlineMedia: OfflineMedia? = nil
|
||||||
) {
|
) {
|
||||||
self.ratingKey = ratingKey
|
self.ratingKey = ratingKey
|
||||||
@@ -43,6 +46,7 @@ public struct DownloadPlan: Sendable {
|
|||||||
self.episodeIndex = episodeIndex
|
self.episodeIndex = episodeIndex
|
||||||
self.durationMilliseconds = durationMilliseconds
|
self.durationMilliseconds = durationMilliseconds
|
||||||
self.expectedBytes = expectedBytes
|
self.expectedBytes = expectedBytes
|
||||||
|
self.expectedIsExact = expectedIsExact
|
||||||
self.offlineMedia = offlineMedia
|
self.offlineMedia = offlineMedia
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,6 +84,28 @@ public final class DownloadCoordinator {
|
|||||||
/// Wie viel die Downloads insgesamt belegen.
|
/// Wie viel die Downloads insgesamt belegen.
|
||||||
public private(set) var usedBytes: Int64 = 0
|
public private(set) var usedBytes: Int64 = 0
|
||||||
|
|
||||||
|
/// Das gemessene Tempo je laufender Übertragung.
|
||||||
|
///
|
||||||
|
/// **Im Arbeitsspeicher, nicht in der Datenbank** — aus demselben Grund,
|
||||||
|
/// aus dem `handleProgress` kein `save()` ruft: die Messpunkte kommen
|
||||||
|
/// mehrmals je Sekunde, und jedes Schreiben wäre eine Plattenoperation.
|
||||||
|
/// Über einen Neustart hinweg wäre eine alte Rate ohnehin wertlos.
|
||||||
|
private var rates: [String: DownloadRate] = [:]
|
||||||
|
|
||||||
|
/// Für welche Titel der Server die Größe **angekündigt** hat.
|
||||||
|
///
|
||||||
|
/// Der Unterschied entscheidet, wann eine Datei als abgerissen gilt. Bei
|
||||||
|
/// einer angekündigten Größe ist jedes fehlende Byte ein Abriss; bei einer
|
||||||
|
/// hochgerechneten (Transcoder: Bitrate × Dauer) wäre dieselbe Strenge
|
||||||
|
/// falsch, denn die Schätzung weicht von Haus aus ab.
|
||||||
|
///
|
||||||
|
/// Woran man es erkennt: `URLSession` meldet die erwartete Größe nur, wenn
|
||||||
|
/// die Antwort eine `Content-Length` trägt — sonst kommt −1.
|
||||||
|
///
|
||||||
|
/// Ebenfalls im Arbeitsspeicher: nach einem Neustart wird die Übertragung
|
||||||
|
/// neu eingereiht, und dann meldet sich der Server wieder.
|
||||||
|
private var announcedSizes: Set<String> = []
|
||||||
|
|
||||||
/// Nur über WLAN übertragen.
|
/// Nur über WLAN übertragen.
|
||||||
public var isWiFiOnly: Bool {
|
public var isWiFiOnly: Bool {
|
||||||
didSet { defaults.set(isWiFiOnly, forKey: Key.wifiOnly) }
|
didSet { defaults.set(isWiFiOnly, forKey: Key.wifiOnly) }
|
||||||
@@ -279,7 +305,8 @@ public final class DownloadCoordinator {
|
|||||||
seasonIndex: plan.seasonIndex,
|
seasonIndex: plan.seasonIndex,
|
||||||
episodeIndex: plan.episodeIndex,
|
episodeIndex: plan.episodeIndex,
|
||||||
durationMilliseconds: plan.durationMilliseconds,
|
durationMilliseconds: plan.durationMilliseconds,
|
||||||
offlineMediaData: plan.offlineMedia.flatMap { try? JSONEncoder().encode($0) }
|
offlineMediaData: plan.offlineMedia.flatMap { try? JSONEncoder().encode($0) },
|
||||||
|
expectedIsExact: plan.expectedIsExact
|
||||||
)
|
)
|
||||||
context.insert(item)
|
context.insert(item)
|
||||||
save()
|
save()
|
||||||
@@ -343,6 +370,7 @@ public final class DownloadCoordinator {
|
|||||||
task.cancel { continuation.resume(returning: $0) }
|
task.cancel { continuation.resume(returning: $0) }
|
||||||
}
|
}
|
||||||
item.resumeData = data
|
item.resumeData = data
|
||||||
|
forgetTransfer(item.ratingKey)
|
||||||
item.state = .paused
|
item.state = .paused
|
||||||
save()
|
save()
|
||||||
await startNextIfPossible()
|
await startNextIfPossible()
|
||||||
@@ -364,6 +392,7 @@ public final class DownloadCoordinator {
|
|||||||
|
|
||||||
/// Entfernt Eintrag und Datei.
|
/// Entfernt Eintrag und Datei.
|
||||||
public func remove(_ item: DownloadItem) {
|
public func remove(_ item: DownloadItem) {
|
||||||
|
forgetTransfer(item.ratingKey)
|
||||||
fileStore.remove(fileName: item.localFileName)
|
fileStore.remove(fileName: item.localFileName)
|
||||||
fileStore.remove(fileName: item.artworkFileName)
|
fileStore.remove(fileName: item.artworkFileName)
|
||||||
context.delete(item)
|
context.delete(item)
|
||||||
@@ -462,13 +491,40 @@ public final class DownloadCoordinator {
|
|||||||
func handleProgress(ratingKey: String, received: Int64, expected: Int64) {
|
func handleProgress(ratingKey: String, received: Int64, expected: Int64) {
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||||
item.receivedBytes = received
|
item.receivedBytes = received
|
||||||
if expected > 0 { item.totalBytes = expected }
|
if expected > 0 {
|
||||||
|
item.totalBytes = expected
|
||||||
|
// Hier und nur hier ist belegt, dass die Größe vom Server kommt
|
||||||
|
// und nicht hochgerechnet ist. Siehe `announcedSizes`.
|
||||||
|
announcedSizes.insert(ratingKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rate = rates[ratingKey] ?? DownloadRate()
|
||||||
|
rate.record(bytes: received, at: .now)
|
||||||
|
rates[ratingKey] = rate
|
||||||
|
|
||||||
updateActivity()
|
updateActivity()
|
||||||
// Bewusst ohne `save()`: der Fortschritt kommt mehrmals je Sekunde, und
|
// Bewusst ohne `save()`: der Fortschritt kommt mehrmals je Sekunde, und
|
||||||
// jedes Schreiben wäre eine Plattenoperation. Für die Anzeige reicht
|
// jedes Schreiben wäre eine Plattenoperation. Für die Anzeige reicht
|
||||||
// die Beobachtung; gespeichert wird bei Zustandswechseln.
|
// die Beobachtung; gespeichert wird bei Zustandswechseln.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das gemessene Tempo einer laufenden Übertragung.
|
||||||
|
///
|
||||||
|
/// `nil`, solange noch nichts gemessen wurde — und das ist eine ehrlichere
|
||||||
|
/// Antwort als „0 MB/s".
|
||||||
|
public func rate(forRatingKey key: String) -> DownloadRate? {
|
||||||
|
rates[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vergisst, was nur zu einer laufenden Übertragung gehört.
|
||||||
|
///
|
||||||
|
/// Beim nächsten Anlauf wird neu gemessen: eine Rate von vorhin gälte
|
||||||
|
/// sonst für eine Leitung, die es inzwischen nicht mehr gibt.
|
||||||
|
private func forgetTransfer(_ ratingKey: String) {
|
||||||
|
rates[ratingKey] = nil
|
||||||
|
announcedSizes.remove(ratingKey)
|
||||||
|
}
|
||||||
|
|
||||||
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||||
let sourceURL = URL(string: item.sourceURLString)
|
let sourceURL = URL(string: item.sourceURLString)
|
||||||
@@ -483,13 +539,19 @@ public final class DownloadCoordinator {
|
|||||||
// Fehler verdeckt: kamen nur ein paar Kilobyte an, wurden sie als
|
// Fehler verdeckt: kamen nur ein paar Kilobyte an, wurden sie als
|
||||||
// empfangen *und* als erwartet notiert — und die Anzeige stand auf
|
// empfangen *und* als erwartet notiert — und die Anzeige stand auf
|
||||||
// hundert Prozent.
|
// hundert Prozent.
|
||||||
if let problem = Self.implausibility(written: written, expected: item.totalBytes) {
|
let problem = Self.implausibility(
|
||||||
|
written: written,
|
||||||
|
expected: item.totalBytes,
|
||||||
|
announced: item.expectedIsExact || announcedSizes.contains(ratingKey)
|
||||||
|
)
|
||||||
|
if let problem {
|
||||||
fileStore.remove(fileName: name)
|
fileStore.remove(fileName: name)
|
||||||
fail(item, message: problem)
|
fail(item, message: problem)
|
||||||
Task { await startNextIfPossible() }
|
Task { await startNextIfPossible() }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forgetTransfer(ratingKey)
|
||||||
item.localFileName = name
|
item.localFileName = name
|
||||||
item.receivedBytes = written
|
item.receivedBytes = written
|
||||||
if item.totalBytes <= 0 { item.totalBytes = written }
|
if item.totalBytes <= 0 { item.totalBytes = written }
|
||||||
@@ -508,10 +570,22 @@ public final class DownloadCoordinator {
|
|||||||
/// Prüft, ob die fertige Datei plausibel ist.
|
/// Prüft, ob die fertige Datei plausibel ist.
|
||||||
///
|
///
|
||||||
/// Gibt eine Begründung zurück, wenn nicht. Zwei Fälle fallen darunter:
|
/// Gibt eine Begründung zurück, wenn nicht. Zwei Fälle fallen darunter:
|
||||||
/// eine winzige Datei (fast immer eine Fehlerseite) und eine, die deutlich
|
/// eine winzige Datei (fast immer eine Fehlerseite) und eine, die unter
|
||||||
/// unter der angekündigten Größe liegt (abgebrochene Übertragung, die als
|
/// der erwarteten Größe liegt (abgebrochene Übertragung, die als
|
||||||
/// erfolgreich gemeldet wurde).
|
/// erfolgreich gemeldet wurde).
|
||||||
static func implausibility(written: Int64, expected: Int64) -> String? {
|
///
|
||||||
|
/// **`announced` entscheidet, wie streng gemessen wird** — und daran hing
|
||||||
|
/// ein Fehler, der Dateien kaputt machte und als Erfolg verbuchte. Hier
|
||||||
|
/// stand früher für jede Größe dieselbe Regel: unter der Hälfte ist es ein
|
||||||
|
/// Abriss, darüber in Ordnung. Eine bei sechzig Prozent abgerissene Datei
|
||||||
|
/// kam damit durch, bekam einen Haken — und brach beim Abspielen
|
||||||
|
/// mittendrin ab.
|
||||||
|
///
|
||||||
|
/// Die Hälfte-Regel war nie für diesen Fall gedacht, sondern für die
|
||||||
|
/// **hochgerechnete** Größe der Transcoder-Stufen (Bitrate × Dauer). Dort
|
||||||
|
/// ist Abweichung normal. Hat der Server die Größe dagegen angekündigt,
|
||||||
|
/// ist jedes fehlende Byte ein Abriss, und zwar genau einer.
|
||||||
|
static func implausibility(written: Int64, expected: Int64, announced: Bool) -> String? {
|
||||||
// Unter hundert Kilobyte ist keine Videodatei. Dieser Wert liegt
|
// Unter hundert Kilobyte ist keine Videodatei. Dieser Wert liegt
|
||||||
// bewusst niedrig: eine sehr kurze Folge kann klein sein, eine
|
// bewusst niedrig: eine sehr kurze Folge kann klein sein, eine
|
||||||
// HTML-Fehlerseite ist es immer.
|
// HTML-Fehlerseite ist es immer.
|
||||||
@@ -521,9 +595,10 @@ public final class DownloadCoordinator {
|
|||||||
bundle: .module
|
bundle: .module
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Zehn Prozent Abweichung nach unten sind noch erklärbar — der
|
// Angekündigt: auf das Byte genau. Hochgerechnet: die Hälfte, denn der
|
||||||
// Transcoder kennt die Endgröße vorab nicht genau. Die Hälfte nicht.
|
// Transcoder kennt die Endgröße vorab nicht.
|
||||||
if expected > 0, Double(written) < Double(expected) * 0.5 {
|
let untergrenze = announced ? Double(expected) : Double(expected) * 0.5
|
||||||
|
if expected > 0, Double(written) < untergrenze {
|
||||||
return String(
|
return String(
|
||||||
localized: "downloads.failed.truncated \(written.formatted(.byteCount(style: .file))) \(expected.formatted(.byteCount(style: .file)))",
|
localized: "downloads.failed.truncated \(written.formatted(.byteCount(style: .file))) \(expected.formatted(.byteCount(style: .file)))",
|
||||||
bundle: .module
|
bundle: .module
|
||||||
@@ -549,11 +624,49 @@ public final class DownloadCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Den Wiederaufnahme-Datensatz retten.**
|
||||||
|
//
|
||||||
|
// Hier lag der zweite Teil des Fehlers. Es gab ihn nur beim Pausieren
|
||||||
|
// von Hand (`pause(_:)`); nach einem Netzabbruch war er weg, und
|
||||||
|
// „Fortsetzen" lud eine vierzig Gigabyte große Datei wieder ab Byte
|
||||||
|
// null — während der Knopf daneben „Fortsetzen" hieß. Eine
|
||||||
|
// Beschriftung, die der Code nicht halten konnte.
|
||||||
|
//
|
||||||
|
// iOS legt den Datensatz dem Fehler bei, wenn die Übertragung
|
||||||
|
// unterbrochen wurde und fortsetzbar ist. Fehlt er, war es keine
|
||||||
|
// Unterbrechung, sondern eine Ablehnung — und `resumeInterrupted()`
|
||||||
|
// unterscheidet die beiden genau daran.
|
||||||
|
item.resumeData = (error as NSError)
|
||||||
|
.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
||||||
|
|
||||||
fail(item, message: error.localizedDescription)
|
fail(item, message: error.localizedDescription)
|
||||||
Task { await startNextIfPossible() }
|
Task { await startNextIfPossible() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nimmt wieder auf, was nur das Netz unterbrochen hat.
|
||||||
|
///
|
||||||
|
/// **Weil niemand sonst merkt, dass das WLAN zurück ist.** Ein
|
||||||
|
/// gescheiterter Download lag bisher, bis man ihn von Hand antippte — auch
|
||||||
|
/// dann, wenn die Verbindung seit einer Stunde wieder stand.
|
||||||
|
///
|
||||||
|
/// Nur Einträge mit Wiederaufnahme-Datensatz: den gibt es bei einer
|
||||||
|
/// unterbrochenen Übertragung, nicht bei einer abgelehnten. Ein Titel, den
|
||||||
|
/// der Server mit „nicht gefunden" beantwortet, versucht sich damit nicht
|
||||||
|
/// im Kreis.
|
||||||
|
public func resumeInterrupted() async {
|
||||||
|
let unterbrochen = items.filter { $0.state == .failed && $0.resumeData != nil }
|
||||||
|
guard !unterbrochen.isEmpty else { return }
|
||||||
|
|
||||||
|
for item in unterbrochen {
|
||||||
|
item.state = .queued
|
||||||
|
item.failureMessage = nil
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
await startNextIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
private func fail(_ item: DownloadItem, message: String) {
|
private func fail(_ item: DownloadItem, message: String) {
|
||||||
|
forgetTransfer(item.ratingKey)
|
||||||
item.state = .failed
|
item.state = .failed
|
||||||
item.failureMessage = message
|
item.failureMessage = message
|
||||||
save()
|
save()
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ public final class DownloadItem {
|
|||||||
|
|
||||||
public var totalBytes: Int64
|
public var totalBytes: Int64
|
||||||
public var receivedBytes: 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
|
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
|
||||||
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
|
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
|
||||||
@@ -69,7 +80,8 @@ public final class DownloadItem {
|
|||||||
seasonIndex: Int? = nil,
|
seasonIndex: Int? = nil,
|
||||||
episodeIndex: Int? = nil,
|
episodeIndex: Int? = nil,
|
||||||
durationMilliseconds: Int? = nil,
|
durationMilliseconds: Int? = nil,
|
||||||
offlineMediaData: Data? = nil
|
offlineMediaData: Data? = nil,
|
||||||
|
expectedIsExact: Bool = false
|
||||||
) {
|
) {
|
||||||
self.ratingKey = ratingKey
|
self.ratingKey = ratingKey
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -85,6 +97,7 @@ public final class DownloadItem {
|
|||||||
self.episodeIndex = episodeIndex
|
self.episodeIndex = episodeIndex
|
||||||
self.durationMilliseconds = durationMilliseconds
|
self.durationMilliseconds = durationMilliseconds
|
||||||
self.offlineMediaData = offlineMediaData
|
self.offlineMediaData = offlineMediaData
|
||||||
|
self.expectedIsExact = expectedIsExact
|
||||||
self.addedAt = Date()
|
self.addedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ private func temporaryRoot() -> URL {
|
|||||||
@Suite("Download-Warteschlange")
|
@Suite("Download-Warteschlange")
|
||||||
struct DownloadCoordinatorTests {
|
struct DownloadCoordinatorTests {
|
||||||
|
|
||||||
|
@Test("Ob die Größe genau ist, kommt am Eintrag an")
|
||||||
|
func carriesExactSizeFlag() throws {
|
||||||
|
let coordinator = try makeCoordinator(root: temporaryRoot())
|
||||||
|
|
||||||
|
var genau = plan("a", bytes: 20_000_000)
|
||||||
|
genau.expectedIsExact = true
|
||||||
|
let eins = try coordinator.enqueue(genau)
|
||||||
|
#expect(eins.expectedIsExact)
|
||||||
|
|
||||||
|
// Die Voreinstellung ist „geschätzt" — die umgerechneten Stufen
|
||||||
|
// dürfen abweichen, und wer das Feld vergisst, bekommt die mildere
|
||||||
|
// Regel statt verworfener Downloads.
|
||||||
|
let zwei = try coordinator.enqueue(plan("b", bytes: 20_000_000))
|
||||||
|
#expect(!zwei.expectedIsExact)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Ein Titel wird angenommen und steht in der Warteschlange")
|
@Test("Ein Titel wird angenommen und steht in der Warteschlange")
|
||||||
func acceptsFirstDownload() throws {
|
func acceptsFirstDownload() throws {
|
||||||
let coordinator = try makeCoordinator(root: temporaryRoot())
|
let coordinator = try makeCoordinator(root: temporaryRoot())
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import FlixyDownloads
|
||||||
|
|
||||||
|
@Suite("Downloadrate und Restzeit")
|
||||||
|
struct DownloadRateTests {
|
||||||
|
/// Ein fester Anfangszeitpunkt, von dem aus gerechnet wird.
|
||||||
|
///
|
||||||
|
/// Die Uhr kommt von außen herein — deshalb braucht hier nichts zu warten,
|
||||||
|
/// und eine Übertragung über eine Viertelstunde ist in Mikrosekunden
|
||||||
|
/// nachgestellt.
|
||||||
|
private let start = ContinuousClock.now
|
||||||
|
|
||||||
|
@Test("Vor dem ersten Abstand gibt es keine Zahl")
|
||||||
|
func noRateFromASinglePoint() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
rate.record(bytes: 0, at: start)
|
||||||
|
// Null wäre eine Auskunft („es geht nichts durch"), und die stimmt
|
||||||
|
// nicht — es ist nur noch nichts gemessen.
|
||||||
|
#expect(rate.bytesPerSecond == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Gleichmäßiger Zufluss ergibt eine ruhige Zahl")
|
||||||
|
func steadyFlowIsSteady() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
for sekunde in 0...5 {
|
||||||
|
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
|
||||||
|
}
|
||||||
|
// Auf das Byte genau: die Glättung zieht einen Messwert nur dann in
|
||||||
|
// eine Richtung, wenn er vom bisherigen abweicht.
|
||||||
|
#expect(rate.bytesPerSecond == 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein Ausreißer verschiebt die Zahl, aber reißt sie nicht mit")
|
||||||
|
func outlierIsDamped() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
for sekunde in 0...5 {
|
||||||
|
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
|
||||||
|
}
|
||||||
|
// Eine einzelne Sekunde mit dem Zehnfachen.
|
||||||
|
rate.record(bytes: 15_000_000, at: start.advanced(by: .seconds(6)))
|
||||||
|
|
||||||
|
let gemessen = try! #require(rate.bytesPerSecond)
|
||||||
|
// Roh wären es zehn Millionen. Geglättet liegt die Zahl dazwischen —
|
||||||
|
// sie folgt, statt zu springen.
|
||||||
|
#expect(gemessen > 1_000_000)
|
||||||
|
#expect(gemessen < 10_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Messpunkte dichter als eine halbe Sekunde zählen nicht")
|
||||||
|
func samplesTooCloseAreIgnored() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
rate.record(bytes: 0, at: start)
|
||||||
|
rate.record(bytes: 50_000, at: start.advanced(by: .milliseconds(100)))
|
||||||
|
rate.record(bytes: 100_000, at: start.advanced(by: .milliseconds(200)))
|
||||||
|
#expect(rate.bytesPerSecond == nil)
|
||||||
|
|
||||||
|
// Und der Bezugspunkt ist dabei nicht weitergerückt: gemessen wird
|
||||||
|
// über die volle Sekunde seit dem ersten Punkt, nicht über die letzten
|
||||||
|
// achthundert Millisekunden.
|
||||||
|
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||||
|
#expect(rate.bytesPerSecond == 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine lange Stille verfälscht die Rate nicht")
|
||||||
|
func longGapResetsTheMeasurement() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
rate.record(bytes: 0, at: start)
|
||||||
|
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||||
|
#expect(rate.bytesPerSecond == 1_000_000)
|
||||||
|
|
||||||
|
// Eine halbe Minute ohne Rückmeldung heißt nicht „langsam", sondern
|
||||||
|
// „stand still": App angehalten, Gerät gesperrt, Netz weg. Daraus eine
|
||||||
|
// Rate zu rechnen ergäbe dreißig Kilobyte je Sekunde und eine Restzeit
|
||||||
|
// von Stunden.
|
||||||
|
rate.record(bytes: 1_030_000, at: start.advanced(by: .seconds(31)))
|
||||||
|
#expect(rate.bytesPerSecond == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Fängt die Übertragung von vorn an, gilt keine alte Zahl")
|
||||||
|
func restartFromZeroDiscardsTheRate() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
rate.record(bytes: 0, at: start)
|
||||||
|
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||||
|
#expect(rate.bytesPerSecond == 1_000_000)
|
||||||
|
|
||||||
|
// Genau das passiert, wenn ein gescheiterter Download ohne
|
||||||
|
// Wiederaufnahme-Datensatz neu beginnt.
|
||||||
|
rate.record(bytes: 0, at: start.advanced(by: .seconds(2)))
|
||||||
|
#expect(rate.bytesPerSecond == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Restzeit folgt aus Rate und Rest")
|
||||||
|
func remainingFollowsFromRate() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
rate.record(bytes: 0, at: start)
|
||||||
|
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||||
|
|
||||||
|
let rest = try! #require(rate.remaining(received: 1_000_000, total: 11_000_000))
|
||||||
|
#expect(rest == .seconds(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Rate keine Restzeit")
|
||||||
|
func noRemainingWithoutRate() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
rate.record(bytes: 0, at: start)
|
||||||
|
// Eine Restzeit ohne Messung wäre geraten — und geraten sieht man ihr
|
||||||
|
// nicht an.
|
||||||
|
#expect(rate.remaining(received: 0, total: 1_000_000) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ist alles da, gibt es nichts mehr zu schätzen")
|
||||||
|
func noRemainingWhenComplete() {
|
||||||
|
var rate = DownloadRate()
|
||||||
|
rate.record(bytes: 0, at: start)
|
||||||
|
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||||
|
#expect(rate.remaining(received: 1_000_000, total: 1_000_000) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,36 +163,78 @@ struct DownloadPlausibilityTests {
|
|||||||
// „fertig". Angekommen war eine HTML-Fehlerseite, und weil die
|
// „fertig". Angekommen war eine HTML-Fehlerseite, und weil die
|
||||||
// empfangene Größe auch als erwartete notiert wurde, stand die
|
// empfangene Größe auch als erwartete notiert wurde, stand die
|
||||||
// Anzeige auf hundert Prozent.
|
// Anzeige auf hundert Prozent.
|
||||||
#expect(DownloadCoordinator.implausibility(written: 2_048, expected: 873_000_000) != nil)
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 2_048, expected: 873_000_000, announced: true
|
||||||
|
) != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Auch ohne angekündigte Größe fällt eine Fehlerseite auf")
|
@Test("Auch ohne angekündigte Größe fällt eine Fehlerseite auf")
|
||||||
func tinyFileWithoutExpectation() {
|
func tinyFileWithoutExpectation() {
|
||||||
// Beim umgerechneten Download kennt der Server die Endgröße vorab
|
// Beim umgerechneten Download kennt der Server die Endgröße vorab
|
||||||
// nicht — die Untergrenze muss deshalb allein tragen.
|
// nicht — die Untergrenze muss deshalb allein tragen.
|
||||||
#expect(DownloadCoordinator.implausibility(written: 900, expected: 0) != nil)
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 900, expected: 0, announced: false
|
||||||
|
) != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Eine abgebrochene Übertragung fällt auf")
|
@Test("Eine abgebrochene Übertragung fällt auf")
|
||||||
func truncatedTransferIsRejected() {
|
func truncatedTransferIsRejected() {
|
||||||
#expect(DownloadCoordinator.implausibility(written: 200_000_000, expected: 873_000_000) != nil)
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 200_000_000, expected: 873_000_000, announced: false
|
||||||
|
) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Bei sechzig Prozent einer angekündigten Größe ist die Datei kaputt")
|
||||||
|
func truncatedAtSixtyPercentIsRejected() {
|
||||||
|
// **Der Fehler, der diese Unterscheidung nötig gemacht hat.** Hier
|
||||||
|
// galt dieselbe Hälfte-Regel wie für geschätzte Größen, und eine bei
|
||||||
|
// sechzig Prozent abgerissene Datei kam durch: Haken dran, und beim
|
||||||
|
// Abspielen brach sie mittendrin ab. Gemeldet wurde das als
|
||||||
|
// „plötzlich war er fertig".
|
||||||
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 1_800_000_000, expected: 3_000_000_000, announced: true
|
||||||
|
) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Dieselben sechzig Prozent einer Schätzung gehen durch")
|
||||||
|
func truncatedAtSixtyPercentOfEstimatePasses() {
|
||||||
|
// Gleiche Zahlen, andere Herkunft: hochgerechnet aus Bitrate und
|
||||||
|
// Dauer. Dort ist eine Abweichung nach unten normal, und streng zu
|
||||||
|
// prüfen hieße, gültige Downloads zu verwerfen.
|
||||||
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 1_800_000_000, expected: 3_000_000_000, announced: false
|
||||||
|
) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Eine vollständige Datei geht durch")
|
@Test("Eine vollständige Datei geht durch")
|
||||||
func completeFilePasses() {
|
func completeFilePasses() {
|
||||||
#expect(DownloadCoordinator.implausibility(written: 873_000_000, expected: 873_000_000) == nil)
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 873_000_000, expected: 873_000_000, announced: true
|
||||||
|
) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Kleinere Abweichungen sind erlaubt")
|
@Test("Kleinere Abweichungen sind erlaubt, solange die Größe geschätzt ist")
|
||||||
func smallDeviationPasses() {
|
func smallDeviationPasses() {
|
||||||
// Der Transcoder arbeitet mit variabler Bitrate und trifft die
|
// Der Transcoder arbeitet mit variabler Bitrate und trifft die
|
||||||
// geschätzte Größe nie genau. Zu streng zu prüfen hieße, gültige
|
// geschätzte Größe nie genau.
|
||||||
// Downloads zu verwerfen.
|
#expect(DownloadCoordinator.implausibility(
|
||||||
#expect(DownloadCoordinator.implausibility(written: 800_000_000, expected: 873_000_000) == nil)
|
written: 800_000_000, expected: 873_000_000, announced: false
|
||||||
|
) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Dieselbe Abweichung bei angekündigter Größe ist ein Abriss")
|
||||||
|
func smallDeviationOfAnnouncedSizeIsRejected() {
|
||||||
|
// Hat der Server die Größe genannt, gibt es keine erklärbare
|
||||||
|
// Abweichung: was fehlt, fehlt.
|
||||||
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 800_000_000, expected: 873_000_000, announced: true
|
||||||
|
) != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Ohne Erwartung geht eine normale Datei durch")
|
@Test("Ohne Erwartung geht eine normale Datei durch")
|
||||||
func noExpectationPasses() {
|
func noExpectationPasses() {
|
||||||
#expect(DownloadCoordinator.implausibility(written: 500_000_000, expected: 0) == nil)
|
#expect(DownloadCoordinator.implausibility(
|
||||||
|
written: 500_000_000, expected: 0, announced: false
|
||||||
|
) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und
|
"""Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und
|
||||||
stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt."""
|
stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt."""
|
||||||
import json, os, struct, zlib, hashlib
|
import json, os, struct, time, zlib, hashlib
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
@@ -10,6 +10,28 @@ SAMPLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sample.mp4")
|
|||||||
# Was an Wiedergabemeldungen hereinkam — damit sich prüfen lässt, ob die App
|
# Was an Wiedergabemeldungen hereinkam — damit sich prüfen lässt, ob die App
|
||||||
# den Fortschritt tatsächlich meldet.
|
# den Fortschritt tatsächlich meldet.
|
||||||
REPORTS = []
|
REPORTS = []
|
||||||
|
# Die kuenstliche grosse Datei — und was mit ihr passieren soll.
|
||||||
|
#
|
||||||
|
# **Ohne sie ist ein abgebrochener Download nicht nachstellbar**, und genau
|
||||||
|
# daran ist ein Fehler jahrelang vorbeigekommen: eine bei sechzig Prozent
|
||||||
|
# abgerissene Datei galt als fertig. Mit der Beispieldatei (28 KB) laesst sich
|
||||||
|
# das nicht zeigen — sie ist in einem Rutsch da.
|
||||||
|
#
|
||||||
|
# groesse wie gross die Datei sich gibt (Content-Length)
|
||||||
|
# abbruch_bei nach wie vielen Bytes die Verbindung geschlossen wird, 0 = nie
|
||||||
|
# tempo Bytes je Sekunde, 0 = so schnell die Leitung hergibt
|
||||||
|
# anfragen jede Anfrage mit ihrem Bereich — daran ist ablesbar, ob
|
||||||
|
# fortgesetzt oder von vorn begonnen wurde
|
||||||
|
# ohne_laenge keine Content-Length ankuendigen. Dann gilt fuer den Client
|
||||||
|
# das Ende der Verbindung als Ende der Datei — eine abgerissene
|
||||||
|
# Uebertragung sieht damit wie eine vollstaendige aus. Genau
|
||||||
|
# dieser Fall macht Dateien kaputt, ohne dass irgendwo ein
|
||||||
|
# Fehler auftaucht.
|
||||||
|
TRANSFER = {"groesse": 20_000_000, "abbruch_bei": 0, "tempo": 0,
|
||||||
|
"ohne_laenge": 0, "anfragen": []}
|
||||||
|
# Dieselbe Drossel fuer die Beispieldatei. Damit laesst sich der Player lange
|
||||||
|
# genug offen halten, um eine Geste zu messen.
|
||||||
|
VIDEO_TEMPO = {"bytes_je_sekunde": 0}
|
||||||
# Die Benutzerdatenblaetter, je Titel.
|
# Die Benutzerdatenblaetter, je Titel.
|
||||||
#
|
#
|
||||||
# Ohne sie laesst sich „Aus Weiterschauen entfernen" gar nicht pruefen: der
|
# Ohne sie laesst sich „Aus Weiterschauen entfernen" gar nicht pruefen: der
|
||||||
@@ -49,6 +71,8 @@ TITLES = [
|
|||||||
# sieht ein wirkungsloser Filter wie ein wirksamer aus.
|
# sieht ein wirkungsloser Filter wie ein wirksamer aus.
|
||||||
("x1", "Zwischen den Zeilen", "Movie", "Nur in Gemischtes zu finden."),
|
("x1", "Zwischen den Zeilen", "Movie", "Nur in Gemischtes zu finden."),
|
||||||
("x2", "Randnotiz", "Movie", "Ebenfalls nur in Gemischtes."),
|
("x2", "Randnotiz", "Movie", "Ebenfalls nur in Gemischtes."),
|
||||||
|
# Die kuenstliche grosse Datei. Nicht zum Abspielen — darin stehen Nullen.
|
||||||
|
("big1", "Die grosse Datei", "Movie", "Zum Pruefen von Abbruch und Fortsetzen."),
|
||||||
]
|
]
|
||||||
# Welche Titel in welcher Mediathek liegen. Was hier nicht steht, liegt
|
# Welche Titel in welcher Mediathek liegen. Was hier nicht steht, liegt
|
||||||
# ueberall — so war es vorher, und fuer die uebrigen Pruefungen reicht das.
|
# ueberall — so war es vorher, und fuer die uebrigen Pruefungen reicht das.
|
||||||
@@ -72,7 +96,10 @@ def item(key, name, kind, overview, *, resume=None, series=None):
|
|||||||
"MediaSources": [{
|
"MediaSources": [{
|
||||||
"Id": "src-" + key,
|
"Id": "src-" + key,
|
||||||
"Container": "mp4",
|
"Container": "mp4",
|
||||||
"Size": 28135,
|
# Die angekuendigte Groesse. Sie landet als `expectedBytes` im
|
||||||
|
# Download und entscheidet mit darueber, ob eine Datei als
|
||||||
|
# abgerissen gilt.
|
||||||
|
"Size": TRANSFER["groesse"] if key == "big1" else 28135,
|
||||||
"Bitrate": 1_200_000,
|
"Bitrate": 1_200_000,
|
||||||
"RunTimeTicks": 10 * 10_000_000,
|
"RunTimeTicks": 10 * 10_000_000,
|
||||||
"SupportsDirectPlay": True,
|
"SupportsDirectPlay": True,
|
||||||
@@ -251,7 +278,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
# Sowohl `/stream` (abspielen) als auch `/stream.mp4` (herunterladen).
|
# Sowohl `/stream` (abspielen) als auch `/stream.mp4` (herunterladen).
|
||||||
# Umgerechnet wird hier natürlich nichts — es kommt dieselbe Datei.
|
# Umgerechnet wird hier natürlich nichts — es kommt dieselbe Datei.
|
||||||
if p.startswith("/Videos/") and ("/stream" in p):
|
if p.startswith("/Videos/") and ("/stream" in p):
|
||||||
|
if p.split("/")[2] == "big1":
|
||||||
|
return self.serve_transfer()
|
||||||
return self.serve_file(SAMPLE, "video/mp4")
|
return self.serve_file(SAMPLE, "video/mp4")
|
||||||
|
if p == "/__transfer":
|
||||||
|
return self.reply(TRANSFER)
|
||||||
if p.startswith(f"/Users/{USER_ID}/Items/") and p.count("/") == 4:
|
if p.startswith(f"/Users/{USER_ID}/Items/") and p.count("/") == 4:
|
||||||
key = p.rsplit("/", 1)[-1]
|
key = p.rsplit("/", 1)[-1]
|
||||||
match = next((t for t in TITLES if t[0] == key), None)
|
match = next((t for t in TITLES if t[0] == key), None)
|
||||||
@@ -290,6 +321,74 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}]))
|
return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}]))
|
||||||
return self.reply(page([]))
|
return self.reply(page([]))
|
||||||
|
|
||||||
|
def serve_transfer(self):
|
||||||
|
"""Eine beliebig grosse Datei, die auf Wunsch mittendrin abreisst.
|
||||||
|
|
||||||
|
Sie besteht aus Nullen — geprueft wird die Uebertragung, nicht der
|
||||||
|
Inhalt. Bereichsanfragen beantwortet sie wie ein echter Server, und
|
||||||
|
genau daran ist ablesbar, ob die App fortsetzt oder von vorn beginnt:
|
||||||
|
steht im Mitschnitt `bytes=12000000-`, hat sie fortgesetzt.
|
||||||
|
"""
|
||||||
|
gesamt = TRANSFER["groesse"]
|
||||||
|
bereich = self.headers.get("Range")
|
||||||
|
start, status = 0, 200
|
||||||
|
if bereich and bereich.startswith("bytes="):
|
||||||
|
start = int(bereich[6:].split("-")[0] or 0)
|
||||||
|
status = 206
|
||||||
|
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "video/mp4")
|
||||||
|
self.send_header("Accept-Ranges", "bytes")
|
||||||
|
# **Ohne Pruefmarke kein Fortsetzen.** `URLSession` legt dem
|
||||||
|
# Wiederaufnahme-Versuch ein `If-Range` bei; fehlt dem Server eine
|
||||||
|
# Marke, mit der er das beantworten koennte, faengt die Uebertragung
|
||||||
|
# wieder bei null an — ganz gleich, wie gut die App ihren Datensatz
|
||||||
|
# aufbewahrt hat. Gemessen: ohne diese zwei Zeilen kam die zweite
|
||||||
|
# Anfrage ohne `Range` und holte alle 20 MB noch einmal.
|
||||||
|
self.send_header("ETag", f'"big1-{TRANSFER["groesse"]}"')
|
||||||
|
self.send_header("Last-Modified", "Mon, 01 Sep 2025 12:00:00 GMT")
|
||||||
|
if TRANSFER["ohne_laenge"]:
|
||||||
|
# Ohne Laenge und ohne Keep-Alive: das Ende der Verbindung ist das
|
||||||
|
# Ende der Datei. So verhaelt sich ein Transcoder, der die Endgroesse
|
||||||
|
# vorab nicht kennt.
|
||||||
|
self.protocol_version = "HTTP/1.0"
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.close_connection = True
|
||||||
|
else:
|
||||||
|
self.send_header("Content-Length", str(gesamt - start))
|
||||||
|
if status == 206:
|
||||||
|
self.send_header("Content-Range", f"bytes {start}-{gesamt - 1}/{gesamt}")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
grenze = TRANSFER["abbruch_bei"]
|
||||||
|
tempo = TRANSFER["tempo"]
|
||||||
|
block = bytes(64 * 1024)
|
||||||
|
stelle, geliefert = start, 0
|
||||||
|
while stelle < gesamt:
|
||||||
|
if grenze and stelle >= grenze:
|
||||||
|
break
|
||||||
|
rest = gesamt - stelle
|
||||||
|
if grenze:
|
||||||
|
rest = min(rest, grenze - stelle)
|
||||||
|
menge = min(len(block), rest)
|
||||||
|
try:
|
||||||
|
self.wfile.write(block[:menge])
|
||||||
|
self.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
break
|
||||||
|
stelle += menge
|
||||||
|
geliefert += menge
|
||||||
|
if tempo:
|
||||||
|
time.sleep(menge / tempo)
|
||||||
|
|
||||||
|
TRANSFER["anfragen"].append(
|
||||||
|
{"bereich": bereich, "ab": start, "geliefert": geliefert,
|
||||||
|
"abgerissen": bool(grenze) and stelle < gesamt}
|
||||||
|
)
|
||||||
|
# Angekuendigt war mehr, als gekommen ist — die Verbindung muss zu.
|
||||||
|
if stelle < gesamt:
|
||||||
|
self.close_connection = True
|
||||||
|
|
||||||
def serve_file(self, path, content_type):
|
def serve_file(self, path, content_type):
|
||||||
"""Mit Bereichsanfragen — AVPlayer holt eine Datei nie am Stück."""
|
"""Mit Bereichsanfragen — AVPlayer holt eine Datei nie am Stück."""
|
||||||
with open(path, "rb") as handle:
|
with open(path, "rb") as handle:
|
||||||
@@ -311,7 +410,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if status == 206:
|
if status == 206:
|
||||||
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
|
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
|
||||||
|
tempo = VIDEO_TEMPO["bytes_je_sekunde"]
|
||||||
|
if not tempo:
|
||||||
|
return self.wfile.write(body)
|
||||||
|
# Gedrosselt: nur so bleibt der Player lange genug offen, um eine
|
||||||
|
# Geste daran zu messen. Die Beispieldatei ist sonst sofort da.
|
||||||
|
for stelle in range(0, len(body), 4096):
|
||||||
|
try:
|
||||||
|
self.wfile.write(body[stelle:stelle + 4096])
|
||||||
|
self.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
return
|
||||||
|
time.sleep(4096 / tempo)
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
REPORTS.append(("encoding-stopped", self.path))
|
REPORTS.append(("encoding-stopped", self.path))
|
||||||
@@ -330,6 +441,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if url.path.startswith("/Sessions/Playing"):
|
if url.path.startswith("/Sessions/Playing"):
|
||||||
REPORTS.append((url.path, body.decode() if body else ""))
|
REPORTS.append((url.path, body.decode() if body else ""))
|
||||||
return self.reply({})
|
return self.reply({})
|
||||||
|
# Die Uebertragung steuern: Groesse, Abbruchstelle, Tempo — und die
|
||||||
|
# Drossel fuer die Beispieldatei. Alles zur Laufzeit, damit sich
|
||||||
|
# „abreissen lassen, dann fortsetzen" in einem Lauf pruefen laesst.
|
||||||
|
if url.path == "/__transfer":
|
||||||
|
neu = json.loads(body or b"{}")
|
||||||
|
for feld in ("groesse", "abbruch_bei", "tempo", "ohne_laenge"):
|
||||||
|
if feld in neu:
|
||||||
|
TRANSFER[feld] = int(neu[feld])
|
||||||
|
if neu.get("vergessen"):
|
||||||
|
TRANSFER["anfragen"].clear()
|
||||||
|
if "video_tempo" in neu:
|
||||||
|
VIDEO_TEMPO["bytes_je_sekunde"] = int(neu["video_tempo"])
|
||||||
|
return self.reply(TRANSFER)
|
||||||
schluessel = userdata_key(url.path)
|
schluessel = userdata_key(url.path)
|
||||||
if schluessel is not None:
|
if schluessel is not None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user