diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index 47fcf56..931049d 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -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. /// /// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 2d17bdf..9ed0813 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -40,7 +40,7 @@ struct DownloadButton: View { isPresented: $showsQualityChoice, titleVisibility: .visible ) { - ForEach(DownloadQuality.allCases) { quality in + ForEach(availableQualities) { quality in Button(qualityLabel(quality)) { enqueue(quality: quality) } } 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 - /// sagt nichts darüber, ob es aufs Gerät passt. + /// Beim Original steht dabei, was es überhaupt ist: „Original · 720p". + /// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade + /// herunterrechnet oder nicht. private func qualityLabel(_ quality: DownloadQuality) -> String { - let media = item.media.indices.contains(mediaIndex) - ? item.media[mediaIndex] - : item.preferredMedia - let originalBytes = media?.parts.first?.size ?? 0 + let originalBytes = currentMedia?.parts.first?.size ?? 0 let bytes = quality.estimatedBytes( durationMilliseconds: item.duration, 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 } let size = bytes.formatted(.byteCount(style: .file)) diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 3b98c64..437ce84 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -37,16 +37,17 @@ struct HomeView: View { } } .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) - // Unscharf statt schwarz: der Aufmacher läuft hinter der Leiste - // weiter, statt an einer harten Kante zu enden. `.ultraThinMaterial` - // macht genau das — es weichzeichnet, was darunter liegt, ohne es - // zusätzlich abzudunkeln. - .toolbarBackground(.ultraThinMaterial, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - // Helle Symbole und Schrift, weil darunter ein Bild liegt und kein - // heller Grund. + // Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe + // bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild + // lesbar. `.ultraThinMaterial` verschleierte dagegen den halben + // Aufmacher. + .toolbarBackground(.hidden, for: .navigationBar) + // Helle Symbole, weil darunter ein Bild liegt und kein heller Grund. .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { SearchToolbarItem() } .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 02582a5..6af9fab 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -19,6 +19,7 @@ struct PlayerView: View { @State private var model: PlayerModel? @State private var showsControls = true @State private var showsTrackPicker = false + @State private var showsQualityChoice = false @State private var hideTask: Task? @State private var scrubbing: Double? @@ -81,6 +82,28 @@ struct PlayerView: View { .sheet(isPresented: $showsTrackPicker) { 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 { let model = PlayerModel( request: request, @@ -164,30 +187,23 @@ struct PlayerView: View { } if !model.isOffline { - qualityMenu(for: model) + qualityButton(for: model) } } .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 - /// dann gebraucht wird, wenn es gerade ruckelt — und dafür will niemand - /// den Film verlassen. - private func qualityMenu(for model: PlayerModel) -> some View { - Menu { - Picker("player.quality", selection: Binding( - get: { model.quality }, - set: { selected in - Task { await model.select(quality: selected) } - scheduleControlsHide() - } - )) { - ForEach(PlayerQuality.allCases) { option in - Text(option.label).tag(option) - } - } + /// Ein `Menu` stand hier vorher — und war der Grund dafür, dass sich der + /// Player aufhängte: schloss man es durch einen Tipp ins Bild, blieb eine + /// unsichtbare Ebene zurück, die alle weiteren Tipps schluckte. Die + /// Bedienelemente kamen dann nicht mehr, und pausieren ging nicht mehr. + /// Ein `confirmationDialog` räumt sich selbst weg. + private func qualityButton(for model: PlayerModel) -> some View { + Button { + showsQualityChoice = true + hideTask?.cancel() } label: { if model.isChangingQuality { ProgressView().controlSize(.small).tint(.white) @@ -353,6 +369,15 @@ struct PlayerView: View { .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 { if case .playing = model.phase { return true } return false @@ -393,7 +418,7 @@ extension Duration { } extension PlayerQuality { - var label: LocalizedStringKey { + var localizedName: String.LocalizationValue { switch self { case .automatic: "quality.automatic" case .original: "quality.original" diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift index 45e6302..50b64dc 100644 --- a/Packages/FlixyKit/Package.swift +++ b/Packages/FlixyKit/Package.swift @@ -43,7 +43,8 @@ let package = Package( .testTarget(name: "FlixyPlaybackTests", dependencies: ["FlixyPlayback"]), // Warteschlange, Hintergrundübertragung, Offline-Bestand. - .target(name: "FlixyDownloads", dependencies: ["PlexKit"]), + .target(name: "FlixyDownloads", dependencies: ["PlexKit"], + resources: [.process("Localizable.xcstrings")]), .testTarget(name: "FlixyDownloadsTests", dependencies: ["FlixyDownloads"]), ] ) diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift index 48a8ff6..5655574 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift @@ -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. diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Localizable.xcstrings b/Packages/FlixyKit/Sources/FlixyDownloads/Localizable.xcstrings new file mode 100644 index 0000000..3ce8f90 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Localizable.xcstrings @@ -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" +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift index 9542997..f507f2d 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia.swift @@ -130,6 +130,32 @@ public struct PlexMedia: Sendable, Equatable, Codable, Identifiable { 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, /// etwa „4K · HEVC · 38,2 Mbit/s". public var qualityLabel: String { diff --git a/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift index 88bf881..fd36040 100644 --- a/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift +++ b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift @@ -152,3 +152,47 @@ struct DownloadRulesTests { #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) + } +}