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:
@@ -35,6 +35,32 @@ enum DownloadQuality: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Bildhöhe, auf die diese Stufe herunterrechnet.
|
||||||
|
var pixelHeight: Int? {
|
||||||
|
switch self {
|
||||||
|
case .original: nil
|
||||||
|
case .fullHD: 1080
|
||||||
|
case .hd: 720
|
||||||
|
case .low: 480
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Stufen für eine Datei überhaupt sinnvoll sind.
|
||||||
|
///
|
||||||
|
/// Eine 720p-Datei auf „Full HD" hochzurechnen bringt nichts außer einer
|
||||||
|
/// größeren Datei — und im Auswahldialog standen dann drei Einträge mit
|
||||||
|
/// derselben geschätzten Größe untereinander, was aussah, als wäre die
|
||||||
|
/// Schätzung kaputt. Sie war es nicht; die Stufen waren es.
|
||||||
|
static func available(forSourceHeight height: Int?) -> [DownloadQuality] {
|
||||||
|
guard let height else { return allCases }
|
||||||
|
// Zehn Prozent Spielraum: 1080p-Dateien sind oft 1072 oder 1088 hoch.
|
||||||
|
let threshold = Double(height) * 0.9
|
||||||
|
return allCases.filter { quality in
|
||||||
|
guard let target = quality.pixelHeight else { return true }
|
||||||
|
return Double(target) < threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
|
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
|
||||||
///
|
///
|
||||||
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
|
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ struct DownloadButton: View {
|
|||||||
isPresented: $showsQualityChoice,
|
isPresented: $showsQualityChoice,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
ForEach(DownloadQuality.allCases) { quality in
|
ForEach(availableQualities) { quality in
|
||||||
Button(qualityLabel(quality)) { enqueue(quality: quality) }
|
Button(qualityLabel(quality)) { enqueue(quality: quality) }
|
||||||
}
|
}
|
||||||
Button("action.cancel", role: .cancel) {}
|
Button("action.cancel", role: .cancel) {}
|
||||||
@@ -108,21 +108,32 @@ struct DownloadButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Beschriftung samt geschätzter Größe.
|
private var currentMedia: PlexMedia? {
|
||||||
|
item.media.indices.contains(mediaIndex) ? item.media[mediaIndex] : item.preferredMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur Stufen, die unter der Auflösung der Datei liegen.
|
||||||
|
private var availableQualities: [DownloadQuality] {
|
||||||
|
DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Beschriftung samt Auflösung und geschätzter Größe.
|
||||||
///
|
///
|
||||||
/// Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein
|
/// Beim Original steht dabei, was es überhaupt ist: „Original · 720p".
|
||||||
/// sagt nichts darüber, ob es aufs Gerät passt.
|
/// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade
|
||||||
|
/// herunterrechnet oder nicht.
|
||||||
private func qualityLabel(_ quality: DownloadQuality) -> String {
|
private func qualityLabel(_ quality: DownloadQuality) -> String {
|
||||||
let media = item.media.indices.contains(mediaIndex)
|
let originalBytes = currentMedia?.parts.first?.size ?? 0
|
||||||
? item.media[mediaIndex]
|
|
||||||
: item.preferredMedia
|
|
||||||
let originalBytes = media?.parts.first?.size ?? 0
|
|
||||||
let bytes = quality.estimatedBytes(
|
let bytes = quality.estimatedBytes(
|
||||||
durationMilliseconds: item.duration,
|
durationMilliseconds: item.duration,
|
||||||
originalBytes: originalBytes
|
originalBytes: originalBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
let name = String(localized: quality.localizedName)
|
var name = String(localized: quality.localizedName)
|
||||||
|
if quality == .original, let resolution = currentMedia?.resolutionLabel {
|
||||||
|
name += " · \(resolution)"
|
||||||
|
}
|
||||||
|
|
||||||
guard bytes > 0 else { return name }
|
guard bytes > 0 else { return name }
|
||||||
|
|
||||||
let size = bytes.formatted(.byteCount(style: .file))
|
let size = bytes.formatted(.byteCount(style: .file))
|
||||||
|
|||||||
@@ -37,16 +37,17 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.flixyCanvas)
|
||||||
.navigationTitle(Branding.appName)
|
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
|
||||||
|
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
|
||||||
|
// ohnehin mehr.
|
||||||
|
.navigationTitle("")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
// Unscharf statt schwarz: der Aufmacher läuft hinter der Leiste
|
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
|
||||||
// weiter, statt an einer harten Kante zu enden. `.ultraThinMaterial`
|
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
|
||||||
// macht genau das — es weichzeichnet, was darunter liegt, ohne es
|
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
|
||||||
// zusätzlich abzudunkeln.
|
// Aufmacher.
|
||||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
// Helle Symbole, weil darunter ein Bild liegt und kein heller Grund.
|
||||||
// Helle Symbole und Schrift, weil darunter ein Bild liegt und kein
|
|
||||||
// heller Grund.
|
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
.toolbar { SearchToolbarItem() }
|
.toolbar { SearchToolbarItem() }
|
||||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct PlayerView: View {
|
|||||||
@State private var model: PlayerModel?
|
@State private var model: PlayerModel?
|
||||||
@State private var showsControls = true
|
@State private var showsControls = true
|
||||||
@State private var showsTrackPicker = false
|
@State private var showsTrackPicker = false
|
||||||
|
@State private var showsQualityChoice = false
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@State private var hideTask: Task<Void, Never>?
|
||||||
@State private var scrubbing: Double?
|
@State private var scrubbing: Double?
|
||||||
|
|
||||||
@@ -81,6 +82,28 @@ struct PlayerView: View {
|
|||||||
.sheet(isPresented: $showsTrackPicker) {
|
.sheet(isPresented: $showsTrackPicker) {
|
||||||
if let model { TrackPickerSheet(model: model) }
|
if let model { TrackPickerSheet(model: model) }
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
Text("player.quality"),
|
||||||
|
isPresented: $showsQualityChoice,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
if let model {
|
||||||
|
ForEach(PlayerQuality.allCases) { option in
|
||||||
|
Button(qualityChoiceLabel(option, current: model.quality)) {
|
||||||
|
Task { await model.select(quality: option) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("action.cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
|
.onChange(of: showsQualityChoice) { _, isShown in
|
||||||
|
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||||
|
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
||||||
|
// weil die alte Frist während des Dialogs abgelaufen ist.
|
||||||
|
guard !isShown else { return }
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
|
||||||
|
scheduleControlsHide()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
let model = PlayerModel(
|
let model = PlayerModel(
|
||||||
request: request,
|
request: request,
|
||||||
@@ -164,30 +187,23 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !model.isOffline {
|
if !model.isOffline {
|
||||||
qualityMenu(for: model)
|
qualityButton(for: model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Qualitätsstufe für diesen Film.
|
/// Der Knopf für die Qualitätsstufe.
|
||||||
///
|
///
|
||||||
/// Im Player und nicht nur in den Einstellungen, weil der Eingriff genau
|
/// Ein `Menu` stand hier vorher — und war der Grund dafür, dass sich der
|
||||||
/// dann gebraucht wird, wenn es gerade ruckelt — und dafür will niemand
|
/// Player aufhängte: schloss man es durch einen Tipp ins Bild, blieb eine
|
||||||
/// den Film verlassen.
|
/// unsichtbare Ebene zurück, die alle weiteren Tipps schluckte. Die
|
||||||
private func qualityMenu(for model: PlayerModel) -> some View {
|
/// Bedienelemente kamen dann nicht mehr, und pausieren ging nicht mehr.
|
||||||
Menu {
|
/// Ein `confirmationDialog` räumt sich selbst weg.
|
||||||
Picker("player.quality", selection: Binding(
|
private func qualityButton(for model: PlayerModel) -> some View {
|
||||||
get: { model.quality },
|
Button {
|
||||||
set: { selected in
|
showsQualityChoice = true
|
||||||
Task { await model.select(quality: selected) }
|
hideTask?.cancel()
|
||||||
scheduleControlsHide()
|
|
||||||
}
|
|
||||||
)) {
|
|
||||||
ForEach(PlayerQuality.allCases) { option in
|
|
||||||
Text(option.label).tag(option)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
if model.isChangingQuality {
|
if model.isChangingQuality {
|
||||||
ProgressView().controlSize(.small).tint(.white)
|
ProgressView().controlSize(.small).tint(.white)
|
||||||
@@ -353,6 +369,15 @@ struct PlayerView: View {
|
|||||||
.padding(FlixySpacing.section)
|
.padding(FlixySpacing.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die gewählte Stufe bekommt einen Haken.
|
||||||
|
///
|
||||||
|
/// Ein `Picker` im Dialog wäre eleganter, aber `confirmationDialog` zeigt
|
||||||
|
/// nur Knöpfe — und die Auswahl muss trotzdem erkennbar sein.
|
||||||
|
private func qualityChoiceLabel(_ option: PlayerQuality, current: PlayerQuality) -> String {
|
||||||
|
let name = String(localized: option.localizedName)
|
||||||
|
return option == current ? "\(name) ✓" : name
|
||||||
|
}
|
||||||
|
|
||||||
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
|
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
|
||||||
if case .playing = model.phase { return true }
|
if case .playing = model.phase { return true }
|
||||||
return false
|
return false
|
||||||
@@ -393,7 +418,7 @@ extension Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension PlayerQuality {
|
extension PlayerQuality {
|
||||||
var label: LocalizedStringKey {
|
var localizedName: String.LocalizationValue {
|
||||||
switch self {
|
switch self {
|
||||||
case .automatic: "quality.automatic"
|
case .automatic: "quality.automatic"
|
||||||
case .original: "quality.original"
|
case .original: "quality.original"
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ let package = Package(
|
|||||||
.testTarget(name: "FlixyPlaybackTests", dependencies: ["FlixyPlayback"]),
|
.testTarget(name: "FlixyPlaybackTests", dependencies: ["FlixyPlayback"]),
|
||||||
|
|
||||||
// Warteschlange, Hintergrundübertragung, Offline-Bestand.
|
// Warteschlange, Hintergrundübertragung, Offline-Bestand.
|
||||||
.target(name: "FlixyDownloads", dependencies: ["PlexKit"]),
|
.target(name: "FlixyDownloads", dependencies: ["PlexKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "FlixyDownloadsTests", dependencies: ["FlixyDownloads"]),
|
.testTarget(name: "FlixyDownloadsTests", dependencies: ["FlixyDownloads"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -468,9 +468,22 @@ public final class DownloadCoordinator {
|
|||||||
let name = fileStore.fileName(forRatingKey: ratingKey, sourceURL: sourceURL)
|
let name = fileStore.fileName(forRatingKey: ratingKey, sourceURL: sourceURL)
|
||||||
do {
|
do {
|
||||||
_ = try fileStore.adopt(temporaryFile: temporaryFile, as: name)
|
_ = 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.localFileName = name
|
||||||
item.receivedBytes = fileStore.size(ofFileName: name)
|
item.receivedBytes = written
|
||||||
item.totalBytes = item.receivedBytes
|
if item.totalBytes <= 0 { item.totalBytes = written }
|
||||||
item.state = .completed
|
item.state = .completed
|
||||||
item.completedAt = Date()
|
item.completedAt = Date()
|
||||||
save()
|
save()
|
||||||
@@ -483,6 +496,40 @@ public final class DownloadCoordinator {
|
|||||||
Task { await startNextIfPossible() }
|
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) {
|
func handleFailure(ratingKey: String, error: any Error) {
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
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 }
|
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.
|
// Die temporäre Datei verschwindet, sobald dieser Aufruf zurückkehrt.
|
||||||
// Sie muss deshalb hier und jetzt in Sicherheit gebracht werden, nicht
|
// Sie muss deshalb hier und jetzt in Sicherheit gebracht werden, nicht
|
||||||
// erst nach einem Sprung auf den Hauptakteur.
|
// erst nach einem Sprung auf den Hauptakteur.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"downloads.failed.tooSmall %@": {
|
||||||
|
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server hat nur %@ geliefert — das ist keine Videodatei."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server returned only %@ — that isn't a video file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.failed.truncated %@ %@": {
|
||||||
|
"comment": "Die Übertragung brach ab, wurde aber als erfolgreich gemeldet",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Übertragung unvollständig: %@ von %@ angekommen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Transfer incomplete: %@ of %@ received."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -130,6 +130,32 @@ public struct PlexMedia: Sendable, Equatable, Codable, Identifiable {
|
|||||||
self.parts = try container.decodeIfPresent([PlexPart].self, forKey: .parts) ?? []
|
self.parts = try container.decodeIfPresent([PlexPart].self, forKey: .parts) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Bildhöhe in Bildpunkten.
|
||||||
|
///
|
||||||
|
/// Bevorzugt aus `height`. Fehlt die, wird `videoResolution` gedeutet —
|
||||||
|
/// dort steht bei Plex kein Zahlenwert, sondern „1080", „4k" oder „sd".
|
||||||
|
public var pixelHeight: Int? {
|
||||||
|
if let height, height > 0 { return height }
|
||||||
|
guard let videoResolution = videoResolution?.lowercased() else { return nil }
|
||||||
|
switch videoResolution {
|
||||||
|
case "4k": return 2160
|
||||||
|
case "sd": return 480
|
||||||
|
default: return Int(videoResolution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Auflösung in der Schreibweise, die Menschen erwarten.
|
||||||
|
public var resolutionLabel: String? {
|
||||||
|
guard let pixelHeight else { return videoResolution?.uppercased() }
|
||||||
|
switch pixelHeight {
|
||||||
|
case 1_600...: return "4K"
|
||||||
|
case 1_000..<1_600: return "1080p"
|
||||||
|
case 700..<1_000: return "720p"
|
||||||
|
case 1..<700: return "SD"
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite,
|
/// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite,
|
||||||
/// etwa „4K · HEVC · 38,2 Mbit/s".
|
/// etwa „4K · HEVC · 38,2 Mbit/s".
|
||||||
public var qualityLabel: String {
|
public var qualityLabel: String {
|
||||||
|
|||||||
@@ -152,3 +152,47 @@ struct DownloadRulesTests {
|
|||||||
#expect(rules.seriesNeedingRefill(in: [item("1"), item("2")]).isEmpty)
|
#expect(rules.seriesNeedingRefill(in: [item("1"), item("2")]).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Plausibilität fertiger Downloads")
|
||||||
|
struct DownloadPlausibilityTests {
|
||||||
|
|
||||||
|
@Test("Eine Fehlerseite geht nicht als Film durch")
|
||||||
|
func tinyFileIsRejected() {
|
||||||
|
// Der Fall aus der Praxis: 873 MB angekündigt, in einer Sekunde
|
||||||
|
// „fertig". Angekommen war eine HTML-Fehlerseite, und weil die
|
||||||
|
// empfangene Größe auch als erwartete notiert wurde, stand die
|
||||||
|
// Anzeige auf hundert Prozent.
|
||||||
|
#expect(DownloadCoordinator.implausibility(written: 2_048, expected: 873_000_000) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Auch ohne angekündigte Größe fällt eine Fehlerseite auf")
|
||||||
|
func tinyFileWithoutExpectation() {
|
||||||
|
// Beim umgerechneten Download kennt der Server die Endgröße vorab
|
||||||
|
// nicht — die Untergrenze muss deshalb allein tragen.
|
||||||
|
#expect(DownloadCoordinator.implausibility(written: 900, expected: 0) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine abgebrochene Übertragung fällt auf")
|
||||||
|
func truncatedTransferIsRejected() {
|
||||||
|
#expect(DownloadCoordinator.implausibility(written: 200_000_000, expected: 873_000_000) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine vollständige Datei geht durch")
|
||||||
|
func completeFilePasses() {
|
||||||
|
#expect(DownloadCoordinator.implausibility(written: 873_000_000, expected: 873_000_000) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Kleinere Abweichungen sind erlaubt")
|
||||||
|
func smallDeviationPasses() {
|
||||||
|
// Der Transcoder arbeitet mit variabler Bitrate und trifft die
|
||||||
|
// geschätzte Größe nie genau. Zu streng zu prüfen hieße, gültige
|
||||||
|
// Downloads zu verwerfen.
|
||||||
|
#expect(DownloadCoordinator.implausibility(written: 800_000_000, expected: 873_000_000) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Erwartung geht eine normale Datei durch")
|
||||||
|
func noExpectationPasses() {
|
||||||
|
#expect(DownloadCoordinator.implausibility(written: 500_000_000, expected: 0) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user