From 62158303a2221b7b444828a53bbfe556e3a67ffe Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 11 Sep 2026 17:34:57 +0200 Subject: [PATCH] Phase 3: Download-Engine und Offline-Wiedergabe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine - Hintergrund-URLSession: läuft weiter, wenn die App zu ist. Der ganze Zustand liegt in SwiftData, weil es nach einem Neustart kein anderes Gedächtnis gibt. - Pausieren über cancel(byProducingResumeData:) — nur so lässt sich weiterladen statt neu zu beginnen. Bei 40 GB der Unterschied zwischen brauchbar und unbrauchbar. - Zwei Übertragungen gleichzeitig, Folgen in Staffel-/Folgenreihenfolge. Alles parallel zu laden hieße: keine Folge wird fertig. - Speicherlimit und Geräteplatz werden vor dem Annehmen geprüft, ein Gigabyte bleibt als Puffer frei. - Verwaiste Dateien werden beim Start entfernt; sie entstehen, wenn die App genau beim Abschluss beendet wird. - Downloads liegen in Application Support, nicht in Caches (iOS räumt den eigenmächtig leer) und sind von der iCloud-Sicherung ausgenommen. Oberfläche - Download-Knopf auf Detailseiten mit vier Zuständen in einem Knopf. - „Alle Folgen laden" für Staffel und Serie; ein abgelehnter Titel stoppt den Stapel nicht. - Downloads-Ansicht mit Belegung, WLAN-Schalter, Pausieren, Fortsetzen, Löschen — nach Serie gruppiert. Offline - Der Player bevorzugt die lokale Datei, auch ohne erreichbaren Server. - Beim Herunterladen wird ein Abzug der Serverangaben mitgesichert: offline lässt sich nichts nachfragen, und der PlaybackDecider braucht Container und Codecs. Ein Fehler, den die Tests aufgedeckt haben: der Koordinator hielt nur den ModelContext, nicht den ModelContainer. Ein ModelContext hält seinen Container nicht am Leben — wird der anderswo freigegeben, stürzt jeder Zugriff auf einen Eintrag ab, und die Meldung zeigt auf ModelContext.reset statt auf die Ursache. Jetzt liegt die Zuständigkeit beim Koordinator. 102 Tests. Co-Authored-By: Claude Opus 5 --- Flixy/FlixyApp.swift | 65 ++- Flixy/Resources/Localizable.xcstrings | 401 ++++++++++++++ Flixy/Session/DownloadService.swift | 145 +++++ Flixy/Views/Detail/PlayableDetailView.swift | 2 + Flixy/Views/Detail/SeriesDetailView.swift | 2 +- Flixy/Views/Downloads/DownloadButton.swift | 165 ++++++ Flixy/Views/Downloads/DownloadsView.swift | 246 +++++++++ Flixy/Views/Navigation/RootView.swift | 2 +- Flixy/Views/Player/PlayerModel.swift | 73 ++- Flixy/Views/Player/PlayerView.swift | 3 + .../Engine/DownloadCoordinator.swift | 520 ++++++++++++++++++ .../Engine/DownloadFileStore.swift | 132 +++++ .../FlixyDownloads/Model/DownloadItem.swift | 161 ++++++ .../DownloadCoordinatorTests.swift | 264 +++++++++ 14 files changed, 2175 insertions(+), 6 deletions(-) create mode 100644 Flixy/Session/DownloadService.swift create mode 100644 Flixy/Views/Downloads/DownloadButton.swift create mode 100644 Flixy/Views/Downloads/DownloadsView.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift create mode 100644 Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadCoordinatorTests.swift diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 7dd1b74..448ebb3 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -1,13 +1,20 @@ +import SwiftData import SwiftUI import FlixyDesign +import FlixyDownloads import FlixyPlayback import PlexKit @main struct FlixyApp: App { + /// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von + /// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen + /// eigenen Weg — nur den klassischen App-Delegaten. + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + /// Leben hier, nicht in einer View: sie gelten für die gesamte App, und - /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch die - /// eingestellte Farbe zurücksetzen. + /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende + /// Downloads zurücksetzen. @State private var theme = FlixyTheme() @State private var session: FlixySession @State private var playback = PlaybackCoordinator() @@ -15,6 +22,9 @@ struct FlixyApp: App { /// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() + @State private var downloads: DownloadCoordinator + + private let modelContainer: ModelContainer init() { let identity = PlexClientIdentity.current( @@ -25,6 +35,18 @@ struct FlixyApp: App { identity: identity, tokenStore: KeychainTokenStore(service: Branding.keychainService) )) + + // Scheitert das Anlegen der Datenbank, ist die App nicht sinnvoll zu + // benutzen — dann lieber hier abbrechen als später bei jedem Download + // stillschweigend nichts tun. + let container = try! ModelContainer(for: DownloadItem.self) + self.modelContainer = container + + let fileStore = try! DownloadFileStore() + _downloads = State(initialValue: DownloadCoordinator( + container: container, + fileStore: fileStore + )) } var body: some Scene { @@ -34,6 +56,7 @@ struct FlixyApp: App { .environment(playback) .environment(playbackSettings) .environment(network) + .environment(downloads) .environment(theme) .flixyAccent(theme) // Der eigentliche Schalter für „nur dunkel" steht in der @@ -41,6 +64,44 @@ struct FlixyApp: App { // Absicherung für SwiftUI-Vorschauen, die sie nicht lesen. .preferredColorScheme(.dark) .tint(theme.accentColor) + .task { + appDelegate.downloads = downloads + await downloads.restore() + } + } + .modelContainer(modelContainer) + } +} + +/// Der klassische App-Delegat, für das eine, was SwiftUI nicht abdeckt. +@MainActor +final class AppDelegate: NSObject, UIApplicationDelegate { + weak var downloads: DownloadCoordinator? + + /// Nicht `@Sendable`: UIKit reicht den Block ohne diese Zusage herein, + /// und er wird ohnehin nur auf dem Hauptakteur aufgerufen. Der Delegat + /// selbst ist an den Hauptakteur gebunden. + private var pendingCompletionHandler: (() -> Void)? + + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + // iOS hat die App im Hintergrund gestartet, weil Downloads fertig + // geworden sind. Der Abschlussblock muss aufgerufen werden, sobald + // alles verarbeitet ist — sonst beendet das System die App hart und + // merkt sich das für künftige Aufweckversuche negativ. + guard identifier == DownloadCoordinator.sessionIdentifier else { + completionHandler() + return + } + pendingCompletionHandler = completionHandler + + Task { [weak self] in + await self?.downloads?.restore() + self?.pendingCompletionHandler?() + self?.pendingCompletionHandler = nil } } } diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index f4d2813..4874438 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2176,6 +2176,407 @@ } } } + }, + "downloads.empty.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nichts offline" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing offline" + } + } + } + }, + "downloads.empty.description": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lade Filme und Folgen herunter, um sie ohne Verbindung anzusehen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download movies and episodes to watch them without a connection." + } + } + } + }, + "downloads.active": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft gerade" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In progress" + } + } + } + }, + "downloads.movies": { + "comment": "Überschrift für heruntergeladene Filme", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Movies" + } + } + } + }, + "downloads.used": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Belegt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" + } + } + } + }, + "downloads.wifiOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur über WLAN laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download over Wi-Fi only" + } + } + } + }, + "downloads.pause": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anhalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + } + } + }, + "downloads.resume": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortsetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume" + } + } + } + }, + "downloads.cancel": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Download abbrechen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel download" + } + } + } + }, + "downloads.download": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + } + } + }, + "downloads.available": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Offline verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Available offline" + } + } + } + }, + "downloads.queued": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wartet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued" + } + } + } + }, + "downloads.paused": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Angehalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Paused" + } + } + } + }, + "downloads.failed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + } + } + }, + "downloads.removeConfirm": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Download entfernen?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove download?" + } + } + } + }, + "downloads.allEpisodes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Folgen laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download all episodes" + } + } + } + }, + "downloads.season": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Staffel laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download season" + } + } + } + }, + "downloads.batch.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + } + } + }, + "downloads.batch.queued %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Folgen in die Warteschlange gelegt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued %lld episodes." + } + } + } + }, + "downloads.batch.unavailable %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Folgen haben keine abspielbare Datei auf dem Server." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld episodes have no playable file on the server." + } + } + } + }, + "downloads.refused.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Download nicht möglich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't download" + } + } + } + }, + "downloads.refused.exists": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Titel steht bereits in der Warteschlange." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This title is already queued." + } + } + } + }, + "downloads.refused.limit %@ %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Speicherlimit ist erreicht: %@ von %@ belegt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Storage limit reached: %@ of %@ used." + } + } + } + }, + "downloads.refused.space %@ %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu wenig Platz auf dem Gerät: %@ nötig, %@ frei." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not enough space on this device: %@ needed, %@ free." + } + } + } + }, + "action.delete": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } } }, "version": "1.1" diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift new file mode 100644 index 0000000..1a21628 --- /dev/null +++ b/Flixy/Session/DownloadService.swift @@ -0,0 +1,145 @@ +import Foundation +import FlixyDownloads +import PlexKit + +/// Übersetzt Titel vom Server in Download-Aufträge. +/// +/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes +/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server +/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt. +@MainActor +struct DownloadService { + let server: PlexServer? + let coordinator: DownloadCoordinator + + /// Baut den Auftrag für einen einzelnen Titel. + /// + /// `nil`, wenn es keine abspielbare Datei gibt — bei einem Titel, den der + /// Server noch nicht analysiert hat, etwa. + func plan(for item: PlexItem, mediaIndex: Int = 0) -> DownloadPlan? { + guard let server else { return nil } + let media = item.media.indices.contains(mediaIndex) + ? item.media[mediaIndex] + : item.preferredMedia + guard let media, let part = media.parts.first, + let url = server.downloadURL(part: part) + else { return nil } + + return DownloadPlan( + ratingKey: item.ratingKey, + title: item.rowTitle, + subtitle: item.downloadSubtitle, + sourceURL: url, + seriesKey: item.grandparentRatingKey, + seriesTitle: item.grandparentTitle, + seasonKey: item.parentRatingKey, + seasonIndex: item.parentIndex, + episodeIndex: item.index, + durationMilliseconds: item.duration, + expectedBytes: part.size ?? 0, + offlineMedia: offlineMedia(for: item, media: media, part: part) + ) + } + + /// Lädt einen einzelnen Titel. + @discardableResult + func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem? { + guard let plan = plan(for: item, mediaIndex: mediaIndex) else { return nil } + return try coordinator.enqueue(plan) + } + + /// Lädt alle Folgen einer Staffel oder Serie. + /// + /// Holt die Metadaten jeder Folge einzeln nach: die Kinderliste enthält + /// keine Dateiangaben, und ohne Dateigröße ließe sich weder das + /// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen. + func downloadAll( + in container: PlexItem + ) async -> (accepted: Int, refused: [(PlexItem, DownloadRefusal)], unavailable: Int) { + guard let server else { return (0, [], 0) } + + let episodes: [PlexItem] + do { + episodes = try await allEpisodes(in: container, server: server) + } catch { + return (0, [], 0) + } + + var accepted = 0 + var refused: [(PlexItem, DownloadRefusal)] = [] + var unavailable = 0 + + for episode in episodes { + // Folgen, die schon offline sind, werden übersprungen statt + // abgelehnt — bei einer Serie, von der die Hälfte geladen ist, + // wäre eine Liste aus zwanzig Fehlern keine Hilfe. + if coordinator.items.contains(where: { $0.ratingKey == episode.ratingKey }) { + continue + } + + let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode + guard let plan = plan(for: detailed) else { + unavailable += 1 + continue + } + + do { + _ = try coordinator.enqueue(plan) + accepted += 1 + } catch { + refused.append((episode, error)) + // Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen + // noch einzeln durchzuprobieren — sie scheitern alle gleich. + if case .storageLimitReached = error { break } + if case .insufficientDeviceSpace = error { break } + } + } + + return (accepted, refused, unavailable) + } + + private func allEpisodes(in container: PlexItem, server: PlexServer) async throws -> [PlexItem] { + switch container.kind { + case .season: + return try await server.children(of: container.ratingKey) + case .show: + var episodes: [PlexItem] = [] + for season in try await server.children(of: container.ratingKey) { + episodes.append(contentsOf: try await server.children(of: season.ratingKey)) + } + return episodes + default: + return [container] + } + } + + /// Der Abzug der Serverangaben, den der Player offline braucht. + private func offlineMedia(for item: PlexItem, media: PlexMedia, part: PlexPart) -> OfflineMedia { + OfflineMedia( + container: part.container ?? media.container, + videoCodec: media.videoCodec, + audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec, + videoResolution: media.videoResolution, + bitrate: media.bitrate, + audioLanguages: part.audioStreams.compactMap(\.language), + subtitleLanguages: part.subtitleStreams.compactMap(\.language), + markers: item.markers.map { + OfflineMarker( + type: $0.type, + startMilliseconds: $0.startTimeOffset, + endMilliseconds: $0.endTimeOffset + ) + } + ) + } +} + +extension PlexItem { + /// Die Zeile unter dem Titel in der Downloads-Ansicht. + var downloadSubtitle: String? { + if kind == .episode, let season = parentIndex, let episode = index { + return "\(String(localized: "episode.short \(season) \(episode)")) · \(title)" + } + return year.map(String.init) + } +} diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index d5f87ea..85655e1 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -96,6 +96,8 @@ struct PlayableDetailView: View { .buttonStyle(.bordered) .tint(Color.flixyTextSecondary) } + + DownloadButton(item: item, mediaIndex: selectedVersion) } } diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 090c792..b810e10 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -47,7 +47,7 @@ struct SeriesDetailView: View { height: 1200 ) ) { - EmptyView() + DownloadAllButton(container: item) } } diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift new file mode 100644 index 0000000..7322780 --- /dev/null +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -0,0 +1,165 @@ +import SwiftUI +import FlixyDesign +import FlixyDownloads +import PlexKit + +/// Der Download-Knopf auf einer Detailseite. +/// +/// Zeigt vier Zustände in einem Knopf: nicht geladen, lädt gerade, offline +/// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die +/// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer. +struct DownloadButton: View { + let item: PlexItem + var mediaIndex: Int = 0 + + @Environment(FlixySession.self) private var session + @Environment(DownloadCoordinator.self) private var downloads + @Environment(\.flixyAccent) private var accent + + @State private var refusal: DownloadRefusal? + + private var existing: DownloadItem? { + downloads.items.first { $0.ratingKey == item.ratingKey } + } + + var body: some View { + Button { + act() + } label: { + Label(label, systemImage: symbol) + .labelStyle(.iconOnly) + .font(.flixyCardTitle) + .padding(FlixySpacing.snug) + } + .buttonStyle(.bordered) + .tint(existing?.state == .completed ? accent : Color.flixyTextSecondary) + .accessibilityLabel(Text(label)) + .alert( + Text("downloads.refused.title"), + isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } }) + ) { + Button("action.close") { refusal = nil } + } message: { + if let refusal { Text(refusal.explanation) } + } + } + + private var symbol: String { + switch existing?.state { + case .completed: "checkmark.circle.fill" + case .downloading, .queued, .waiting: "stop.circle" + case .paused: "play.circle" + case .failed: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90" + case nil: "arrow.down.circle" + } + } + + private var label: LocalizedStringKey { + switch existing?.state { + case .completed: "downloads.available" + case .downloading, .queued, .waiting: "downloads.cancel" + case .paused: "downloads.resume" + case .failed: "action.retry" + case nil: "downloads.download" + } + } + + private func act() { + guard let existing else { + enqueue() + return + } + + switch existing.state { + case .completed: + // Ein fertiger Download wird hier nicht angetastet. Löschen geht + // über die Downloads-Ansicht — ein Fehlgriff in der Kopfzeile + // soll nicht 40 GB verwerfen. + break + case .downloading, .queued, .waiting: + Task { await downloads.cancel(existing) } + case .paused, .failed: + Task { await downloads.resume(existing) } + } + } + + private func enqueue() { + let service = DownloadService(server: session.server, coordinator: downloads) + do { + _ = try service.download(item, mediaIndex: mediaIndex) + } catch { + refusal = error + } + } +} + +/// Der Knopf für eine ganze Staffel oder Serie. +struct DownloadAllButton: View { + let container: PlexItem + + @Environment(FlixySession.self) private var session + @Environment(DownloadCoordinator.self) private var downloads + @Environment(\.flixyAccent) private var accent + + @State private var isWorking = false + @State private var summary: String? + + var body: some View { + Button { + Task { await downloadAll() } + } label: { + if isWorking { + ProgressView().controlSize(.small) + } else { + Label( + container.kind == .show ? "downloads.allEpisodes" : "downloads.season", + systemImage: "arrow.down.circle" + ) + } + } + .buttonStyle(.bordered) + .tint(Color.flixyTextSecondary) + .disabled(isWorking) + .alert( + Text("downloads.batch.title"), + isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } }) + ) { + Button("action.close") { summary = nil } + } message: { + if let summary { Text(summary) } + } + } + + private func downloadAll() async { + isWorking = true + defer { isWorking = false } + + let service = DownloadService(server: session.server, coordinator: downloads) + let result = await service.downloadAll(in: container) + + var lines = [String(localized: "downloads.batch.queued \(result.accepted)")] + if !result.refused.isEmpty { + // Der erste Grund reicht: bei einem erreichten Speicherlimit ist + // er für alle übrigen derselbe. + lines.append(result.refused[0].1.explanation) + } + if result.unavailable > 0 { + lines.append(String(localized: "downloads.batch.unavailable \(result.unavailable)")) + } + summary = lines.joined(separator: "\n") + } +} + +extension DownloadRefusal { + /// Der Grund im Klartext. + var explanation: String { + switch self { + case .alreadyExists: + String(localized: "downloads.refused.exists") + case .storageLimitReached(let limit, let used): + String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))") + case .insufficientDeviceSpace(let required, let available): + String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))") + } + } +} diff --git a/Flixy/Views/Downloads/DownloadsView.swift b/Flixy/Views/Downloads/DownloadsView.swift new file mode 100644 index 0000000..d47eb51 --- /dev/null +++ b/Flixy/Views/Downloads/DownloadsView.swift @@ -0,0 +1,246 @@ +import SwiftUI +import FlixyDesign +import FlixyDownloads + +/// Was offline ist und was gerade geladen wird. +struct DownloadsView: View { + @Environment(DownloadCoordinator.self) private var downloads + @Environment(PlaybackCoordinator.self) private var playback + @Environment(\.flixyAccent) private var accent + + @State private var itemPendingRemoval: DownloadItem? + + var body: some View { + Group { + if downloads.items.isEmpty { + ContentUnavailableView { + Label("downloads.empty.title", systemImage: "arrow.down.circle") + } description: { + Text("downloads.empty.description") + } + } else { + list + } + } + .background(Color.flixyCanvas) + .navigationTitle(Text("sidebar.downloads")) + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + Text("downloads.removeConfirm"), + isPresented: Binding( + get: { itemPendingRemoval != nil }, + set: { if !$0 { itemPendingRemoval = nil } } + ), + titleVisibility: .visible + ) { + Button("action.delete", role: .destructive) { + if let item = itemPendingRemoval { + Task { await downloads.cancel(item) } + } + itemPendingRemoval = nil + } + Button("action.cancel", role: .cancel) { itemPendingRemoval = nil } + } + } + + private var list: some View { + List { + StorageSection() + + // Erst was läuft, dann was fertig ist. Wer die Ansicht öffnet, + // während etwas lädt, will meistens genau das sehen. + if !active.isEmpty { + Section("downloads.active") { + ForEach(active) { item in + DownloadRow(item: item, onRemove: { itemPendingRemoval = item }) + } + } + } + + ForEach(completedGroups, id: \.title) { group in + Section(group.title) { + ForEach(group.items) { item in + DownloadRow(item: item, onRemove: { itemPendingRemoval = item }) + .contentShape(.rect) + .onTapGesture { play(item) } + } + } + } + } + .scrollContentBackground(.hidden) + .listRowBackground(Color.flixySurface) + } + + private var active: [DownloadItem] { + downloads.items.filter { $0.state != .completed } + } + + /// Fertige Downloads, nach Serie gruppiert; Filme stehen zusammen. + private var completedGroups: [(title: String, items: [DownloadItem])] { + let completed = downloads.items.filter { $0.state == .completed } + let grouped = Dictionary(grouping: completed) { + $0.seriesTitle ?? String(localized: "downloads.movies") + } + return grouped + .map { (title: $0.key, items: $0.value.sorted(by: episodeOrder)) } + .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + private func episodeOrder(_ lhs: DownloadItem, _ rhs: DownloadItem) -> Bool { + (lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0) < (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0) + } + + private func play(_ item: DownloadItem) { + guard item.state == .completed else { return } + playback.request = PlaybackRequest( + ratingKey: item.ratingKey, + title: item.title, + mediaIndex: 0, + startTime: .zero + ) + } +} + +/// Die Kopfzeile mit Speicherbelegung und Schaltern. +private struct StorageSection: View { + @Environment(DownloadCoordinator.self) private var downloads + @Environment(\.flixyAccent) private var accent + + var body: some View { + @Bindable var downloads = downloads + + Section { + HStack { + Text("downloads.used") + .foregroundStyle(Color.flixyTextSecondary) + Spacer() + Text(downloads.usedBytes.formatted(.byteCount(style: .file))) + .foregroundStyle(Color.flixyTextPrimary) + } + + if let limit = downloads.storageLimitBytes, limit > 0 { + ProgressView(value: min(Double(downloads.usedBytes) / Double(limit), 1)) + .tint(accent) + .accessibilityLabel(Text("downloads.used")) + } + + Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly) + } + } +} + +/// Eine Zeile in der Downloads-Ansicht. +private struct DownloadRow: View { + let item: DownloadItem + let onRemove: () -> Void + + @Environment(DownloadCoordinator.self) private var downloads + @Environment(\.flixyAccent) private var accent + + var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.snug) { + HStack(spacing: FlixySpacing.regular) { + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.flixyCardTitle) + .foregroundStyle(Color.flixyTextPrimary) + .lineLimit(1) + if let subtitle = item.subtitle { + Text(subtitle) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .lineLimit(1) + } + } + + Spacer() + action + } + + if item.state != .completed { + progress + } + + if let message = item.failureMessage { + Text(message) + .font(.flixyCaption) + .foregroundStyle(Color.flixyDestructive) + .lineLimit(2) + } + } + .padding(.vertical, FlixySpacing.tight) + .listRowBackground(Color.flixySurface) + .swipeActions(edge: .trailing) { + Button("action.delete", role: .destructive, action: onRemove) + } + } + + @ViewBuilder + private var action: some View { + switch item.state { + case .downloading: + Button { + Task { await downloads.pause(item) } + } label: { + Image(systemName: "pause.circle").font(.title3) + } + .buttonStyle(.plain) + .foregroundStyle(accent) + .accessibilityLabel(Text("downloads.pause")) + + case .paused, .failed: + Button { + Task { await downloads.resume(item) } + } label: { + Image(systemName: "arrow.clockwise.circle").font(.title3) + } + .buttonStyle(.plain) + .foregroundStyle(accent) + .accessibilityLabel(Text("downloads.resume")) + + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.flixyTextMuted) + .accessibilityLabel(Text("downloads.available")) + + case .queued, .waiting: + Image(systemName: "clock") + .foregroundStyle(Color.flixyTextMuted) + .accessibilityLabel(Text("downloads.queued")) + } + } + + private var progress: some View { + VStack(alignment: .leading, spacing: FlixySpacing.tight) { + // Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei + // null Prozent, der sich nie bewegt, sieht aus wie ein hängender + // Download. + if let value = item.progress { + ProgressView(value: value).tint(accent) + } else { + ProgressView().progressViewStyle(.linear).tint(accent) + } + + Text(statusLine) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + } + } + + private var statusLine: String { + switch item.state { + case .downloading where item.totalBytes > 0: + "\(item.receivedBytes.formatted(.byteCount(style: .file))) / \(item.totalBytes.formatted(.byteCount(style: .file)))" + case .downloading: + item.receivedBytes.formatted(.byteCount(style: .file)) + case .paused: + String(localized: "downloads.paused") + case .queued, .waiting: + String(localized: "downloads.queued") + case .failed: + String(localized: "downloads.failed") + case .completed: + item.totalBytes.formatted(.byteCount(style: .file)) + } + } +} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 32c3ee4..f771078 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -131,7 +131,7 @@ struct RootView: View { case .liveTV: PlaceholderScreen(destination: .liveTV) case .downloads: - PlaceholderScreen(destination: .downloads) + DownloadsView() case .settings: SettingsView() } diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 210a402..494b04b 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import FlixyDownloads import FlixyPlayback import FlixyVLC import PlexKit @@ -41,9 +42,13 @@ final class PlayerModel { /// damit sie am Ende ohne Wartezeit bereitsteht. private(set) var nextEpisode: PlexItem? private(set) var hasReachedEnd = false + /// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen + /// Fortschritt zu melden und keine nächste Folge nachzuschlagen. + private(set) var isOffline = false private let request: PlaybackRequest private let server: PlexServer? + private let downloads: DownloadCoordinator private let policy: PlaybackPolicy private let settings: PlaybackSettings private let isLocalConnection: Bool @@ -60,12 +65,14 @@ final class PlayerModel { init( request: PlaybackRequest, server: PlexServer?, + downloads: DownloadCoordinator, policy: PlaybackPolicy, settings: PlaybackSettings, isLocalConnection: Bool ) { self.request = request self.server = server + self.downloads = downloads self.policy = policy self.settings = settings self.isLocalConnection = isLocalConnection @@ -75,6 +82,14 @@ final class PlayerModel { // MARK: - Start func start() async { + // Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein + // Datenvolumen und funktioniert auch dann, wenn der Server gerade + // nicht erreichbar ist — der eigentliche Sinn des Herunterladens. + if let localURL = downloads.localURL(forRatingKey: request.ratingKey) { + await startLocal(url: localURL) + return + } + guard let server else { phase = .failed(message: String(localized: "error.serverUnreachable")) return @@ -105,6 +120,59 @@ final class PlayerModel { return } + await load(source, decision: decision) + preloadNextEpisode() + } + + /// Wiedergabe aus dem Download-Ordner. + /// + /// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf + /// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim + /// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen. + private func startLocal(url: URL) async { + let offline = downloads.offlineMedia(forRatingKey: request.ratingKey) + + let decision = PlaybackDecider().decide( + media: PlexMedia( + id: 0, + container: offline?.container, + videoCodec: offline?.videoCodec, + audioCodec: offline?.audioCodec, + videoResolution: offline?.videoResolution, + bitrate: offline?.bitrate + ), + part: PlexPart( + id: 0, + key: url.lastPathComponent, + container: offline?.container, + streams: offline.map(localStreams) ?? [] + ), + // Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze. + policy: .directPlayOnly + ) + self.decision = decision + isOffline = true + + await load( + PlaybackSource(url: url, origin: .local, startTime: request.startTime), + decision: decision + ) + } + + /// Baut künstliche Spuren aus dem Offline-Abzug. + /// + /// Der `PlaybackDecider` erwartet Spuren, um überhaupt zu entscheiden — + /// eine leere Liste hieße „noch nicht analysiert" und schickte jede + /// heruntergeladene Datei vorsichtshalber an VLC. + private func localStreams(from offline: OfflineMedia) -> [PlexStream] { + var streams = [PlexStream(id: 1, streamType: 1, codec: offline.videoCodec)] + if let audioCodec = offline.audioCodec { + streams.append(PlexStream(id: 2, streamType: 2, codec: audioCodec, selected: true)) + } + return streams + } + + private func load(_ source: PlaybackSource, decision: PlaybackDecision) async { let engine = makeEngine(for: decision) self.engine = engine observe(engine) @@ -113,8 +181,7 @@ final class PlayerModel { try await engine.load(source) engine.play() phase = .playing - startReporting() - preloadNextEpisode() + if !isOffline { startReporting() } } catch { phase = .failed(message: String(localized: "player.loadFailed")) } @@ -314,6 +381,8 @@ final class PlayerModel { case .reachedEnd: isPlaying = false hasReachedEnd = true + // Grundlage fürs automatische Löschen nach dem Ansehen. + downloads.markWatched(ratingKey: request.ratingKey) case .tracksResolved(let audio, let subtitle): audioTracks = audio subtitleTracks = subtitle diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 3fbdf07..a13bc0c 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -1,5 +1,6 @@ import SwiftUI import FlixyDesign +import FlixyDownloads import FlixyPlayback import PlexKit @@ -11,6 +12,7 @@ struct PlayerView: View { @Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackSettings.self) private var settings @Environment(NetworkConditions.self) private var network + @Environment(DownloadCoordinator.self) private var downloads @Environment(\.flixyAccent) private var accent @State private var model: PlayerModel? @@ -68,6 +70,7 @@ struct PlayerView: View { let model = PlayerModel( request: request, server: session.server, + downloads: downloads, policy: settings.policy(for: network), settings: settings, isLocalConnection: session.server?.connection.local ?? false diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift new file mode 100644 index 0000000..5fb9cb9 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift @@ -0,0 +1,520 @@ +import Foundation +import Observation +import SwiftData + +/// Was ein Download braucht, um angelegt zu werden. +public struct DownloadPlan: Sendable { + public var ratingKey: String + public var title: String + public var subtitle: String? + public var sourceURL: URL + public var seriesKey: String? + public var seriesTitle: String? + public var seasonKey: String? + public var seasonIndex: Int? + public var episodeIndex: Int? + public var durationMilliseconds: Int? + public var expectedBytes: Int64 + public var offlineMedia: OfflineMedia? + + public init( + ratingKey: String, + title: String, + subtitle: String? = nil, + sourceURL: URL, + seriesKey: String? = nil, + seriesTitle: String? = nil, + seasonKey: String? = nil, + seasonIndex: Int? = nil, + episodeIndex: Int? = nil, + durationMilliseconds: Int? = nil, + expectedBytes: Int64 = 0, + offlineMedia: OfflineMedia? = nil + ) { + self.ratingKey = ratingKey + self.title = title + self.subtitle = subtitle + self.sourceURL = sourceURL + self.seriesKey = seriesKey + self.seriesTitle = seriesTitle + self.seasonKey = seasonKey + self.seasonIndex = seasonIndex + self.episodeIndex = episodeIndex + self.durationMilliseconds = durationMilliseconds + self.expectedBytes = expectedBytes + self.offlineMedia = offlineMedia + } +} + +/// Warum ein Download nicht angenommen wurde. +public enum DownloadRefusal: Error, Equatable, Sendable { + case alreadyExists + /// Das eingestellte Speicherlimit wäre überschritten. + case storageLimitReached(limitBytes: Int64, usedBytes: Int64) + /// Auf dem Gerät ist zu wenig Platz. + case insufficientDeviceSpace(requiredBytes: Int64, availableBytes: Int64) +} + +/// Führt Downloads aus und hält ihren Zustand. +/// +/// Die Übertragung selbst macht eine Hintergrund-`URLSession`: sie läuft +/// weiter, wenn die App geschlossen oder das Gerät gesperrt ist, und iOS +/// startet die App zum Abschluss kurz wieder. Deshalb steht der gesamte +/// Zustand in SwiftData und nicht im Arbeitsspeicher — nach einem Neustart +/// gibt es kein anderes Gedächtnis. +@MainActor +@Observable +public final class DownloadCoordinator { + /// Die Kennung der Hintergrundsitzung. Muss über App-Starts hinweg gleich + /// bleiben, sonst findet iOS die laufenden Übertragungen nicht wieder. + public static let sessionIdentifier = "com.scarriffleservices.flixy.downloads" + + public private(set) var items: [DownloadItem] = [] + /// Wie viel die Downloads insgesamt belegen. + public private(set) var usedBytes: Int64 = 0 + + /// Nur über WLAN übertragen. + public var isWiFiOnly: Bool { + didSet { defaults.set(isWiFiOnly, forKey: Key.wifiOnly) } + } + + /// Obergrenze in Bytes; `nil` heißt unbegrenzt. + public var storageLimitBytes: Int64? { + didSet { defaults.set(storageLimitBytes ?? 0, forKey: Key.storageLimit) } + } + + /// Wie viele Übertragungen gleichzeitig laufen dürfen. + /// + /// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden teilt + /// die Bandbreite so auf, dass keine Folge fertig wird — und unterwegs ist + /// eine fertige Folge mehr wert als zehn halbe. + public var maximumConcurrentDownloads = 2 + + private enum Key { + static let wifiOnly = "flixy.downloads.wifiOnly" + static let storageLimit = "flixy.downloads.storageLimit" + } + + /// Der Container, nicht nur der Kontext. + /// + /// Ein `ModelContext` hält seinen Container nicht am Leben. Wird der + /// anderswo freigegeben, setzt sich der Kontext zurück und jeder bereits + /// geholte Eintrag stürzt beim nächsten Zugriff ab — mit einer Meldung, + /// die auf `ModelContext.reset` zeigt und nichts über die wahre Ursache + /// sagt. Deshalb liegt die Zuständigkeit hier. + private let container: ModelContainer + private var context: ModelContext { container.mainContext } + private let fileStore: DownloadFileStore + private let defaults: UserDefaults + private var session: URLSession! + private let delegate = DownloadSessionDelegate() + + /// Die Konfiguration der Hintergrundsitzung. + /// + /// Als eigene Funktion, damit Tests stattdessen eine flüchtige Sitzung + /// einsetzen können. Eine echte Hintergrundsitzung verlangt eine laufende + /// App mit Bundle-Kennung — im Testprozess gibt es die nicht. + public static func backgroundConfiguration(allowsCellularAccess: Bool) -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) + // `false`, weil der Nutzer den Download selbst angestoßen hat. Auf + // `true` würde iOS ihn auf einen günstigen Zeitpunkt verschieben — + // manchmal erst nachts am Ladegerät. + configuration.isDiscretionary = false + configuration.sessionSendsLaunchEvents = true + configuration.allowsCellularAccess = allowsCellularAccess + return configuration + } + + public init( + container: ModelContainer, + fileStore: DownloadFileStore, + defaults: UserDefaults = .standard, + sessionConfiguration: URLSessionConfiguration? = nil + ) { + self.container = container + self.fileStore = fileStore + self.defaults = defaults + self.isWiFiOnly = defaults.object(forKey: Key.wifiOnly) as? Bool ?? true + let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0 + self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil + + self.session = URLSession( + configuration: sessionConfiguration + ?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly), + delegate: delegate, + delegateQueue: nil + ) + delegate.coordinator = self + } + + // MARK: - Start + + /// Liest den gespeicherten Zustand und bringt ihn mit der Wirklichkeit in + /// Übereinstimmung. + /// + /// Beim Start unerlässlich: während die App weg war, können Übertragungen + /// fertig geworden, gescheitert oder vom System abgebrochen worden sein. + public func restore() async { + reloadItems() + + let runningKeys = Set(await session.allTasks.compactMap(\.taskDescription)) + + for item in items where item.state == .downloading { + // Als „läuft" gespeichert, aber keine Aufgabe dazu: die Übertragung + // ist verloren gegangen. Zurück in die Warteschlange, statt einen + // Fortschrittsbalken zu zeigen, der sich nie bewegt. + if !runningKeys.contains(item.ratingKey) { + item.state = .queued + } + } + + // Dateien ohne Eintrag entfernen — sie entstehen, wenn die App genau + // beim Abschluss beendet wird. + let known = Set(items.compactMap(\.localFileName)) + _ = fileStore.removeOrphans(keeping: known) + + save() + refreshUsage() + await startNextIfPossible() + } + + // MARK: - Hinzufügen + + @discardableResult + public func enqueue(_ plan: DownloadPlan) throws(DownloadRefusal) -> DownloadItem { + if let existing = items.first(where: { $0.ratingKey == plan.ratingKey }) { + // Ein gescheiterter Download darf erneut in die Warteschlange — + // das ist der Wiederholen-Knopf. + guard existing.state == .failed else { throw .alreadyExists } + existing.state = .queued + existing.failureMessage = nil + save() + Task { await startNextIfPossible() } + return existing + } + + try checkCapacity(for: plan.expectedBytes) + + let item = DownloadItem( + ratingKey: plan.ratingKey, + title: plan.title, + subtitle: plan.subtitle, + sourceURLString: plan.sourceURL.absoluteString, + totalBytes: plan.expectedBytes, + seriesKey: plan.seriesKey, + seriesTitle: plan.seriesTitle, + seasonKey: plan.seasonKey, + seasonIndex: plan.seasonIndex, + episodeIndex: plan.episodeIndex, + durationMilliseconds: plan.durationMilliseconds, + offlineMediaData: plan.offlineMedia.flatMap { try? JSONEncoder().encode($0) } + ) + context.insert(item) + save() + reloadItems() + + Task { await startNextIfPossible() } + return item + } + + /// Mehrere Titel auf einmal — eine Staffel oder eine ganze Serie. + /// + /// Gibt zurück, was angenommen wurde und was nicht. Ein einzelner + /// abgelehnter Titel darf den Rest des Stapels nicht verhindern: bei einer + /// Staffel mit einer bereits geladenen Folge wäre sonst gar nichts + /// passiert. + @discardableResult + public func enqueueBatch( + _ plans: [DownloadPlan] + ) -> (accepted: [DownloadItem], refused: [(DownloadPlan, DownloadRefusal)]) { + var accepted: [DownloadItem] = [] + var refused: [(DownloadPlan, DownloadRefusal)] = [] + + for plan in plans { + do { + accepted.append(try enqueue(plan)) + } catch { + refused.append((plan, error)) + } + } + return (accepted, refused) + } + + private func checkCapacity(for expectedBytes: Int64) throws(DownloadRefusal) { + if let limit = storageLimitBytes, usedBytes + expectedBytes > limit { + throw .storageLimitReached(limitBytes: limit, usedBytes: usedBytes) + } + + let available = fileStore.availableCapacity() + // Ein Puffer von einem Gigabyte bleibt frei. Ein Gerät bis zum letzten + // Byte vollzuschreiben legt auch alles andere lahm. + let reserve: Int64 = 1_000_000_000 + if expectedBytes > 0, expectedBytes + reserve > available { + throw .insufficientDeviceSpace(requiredBytes: expectedBytes, availableBytes: available) + } + } + + // MARK: - Steuern + + public func pause(_ item: DownloadItem) async { + guard item.state == .downloading else { return } + guard let task = await task(for: item.ratingKey) as? URLSessionDownloadTask else { + item.state = .paused + save() + return + } + + // `cancel(byProducingResumeData:)` statt `cancel()`: nur so gibt iOS + // den Datensatz heraus, mit dem später weitergeladen werden kann. + // Sonst begänne der Download wieder bei null — bei 40 GB fatal. + let data = await withCheckedContinuation { continuation in + task.cancel { continuation.resume(returning: $0) } + } + item.resumeData = data + item.state = .paused + save() + await startNextIfPossible() + } + + public func resume(_ item: DownloadItem) async { + guard item.state == .paused || item.state == .failed else { return } + item.state = .queued + item.failureMessage = nil + save() + await startNextIfPossible() + } + + public func cancel(_ item: DownloadItem) async { + await task(for: item.ratingKey)?.cancel() + remove(item) + await startNextIfPossible() + } + + /// Entfernt Eintrag und Datei. + public func remove(_ item: DownloadItem) { + fileStore.remove(fileName: item.localFileName) + fileStore.remove(fileName: item.artworkFileName) + context.delete(item) + save() + reloadItems() + refreshUsage() + } + + /// Merkt sich, dass ein Titel durchgesehen wurde. + public func markWatched(ratingKey: String) { + guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return } + item.watchedAt = Date() + save() + } + + /// Die lokale Datei eines fertigen Downloads. + public func localURL(forRatingKey key: String) -> URL? { + guard let item = items.first(where: { $0.ratingKey == key }), + item.state == .completed, + let fileName = item.localFileName, + fileStore.exists(fileName: fileName) + else { return nil } + return fileStore.url(forFileName: fileName) + } + + public func offlineMedia(forRatingKey key: String) -> OfflineMedia? { + guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil } + return try? JSONDecoder().decode(OfflineMedia.self, from: data) + } + + // MARK: - Warteschlange + + /// Startet den nächsten wartenden Download, sofern Platz in der + /// Warteschlange ist. + func startNextIfPossible() async { + let running = items.filter { $0.state == .downloading }.count + guard running < maximumConcurrentDownloads else { return } + + for item in transferOrder.prefix(maximumConcurrentDownloads - running) { + start(item) + } + } + + /// Die Reihenfolge, in der wartende Downloads abgearbeitet werden. + /// + /// Älteste zuerst — außer innerhalb einer Serie, dort nach Staffel und + /// Folge. Sonst wäre bei einer Staffel Folge 7 vor Folge 1 fertig, und + /// unterwegs nützt die siebte wenig, wenn die erste fehlt. + var transferOrder: [DownloadItem] { + items + .filter { $0.state.isTransferable } + .sorted { lhs, rhs in + if lhs.seriesKey == rhs.seriesKey { + let left = (lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0) + let right = (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0) + if left != right { return left < right } + } + return lhs.addedAt < rhs.addedAt + } + } + + private func start(_ item: DownloadItem) { + guard let url = URL(string: item.sourceURLString) else { + fail(item, message: "Ungültige Adresse") + return + } + + let task: URLSessionDownloadTask + if let resumeData = item.resumeData { + task = session.downloadTask(withResumeData: resumeData) + item.resumeData = nil + } else { + var request = URLRequest(url: url) + request.allowsCellularAccess = !isWiFiOnly + task = session.downloadTask(with: request) + } + + // Über die Beschreibung findet der Delegat den Eintrag wieder — auch + // nach einem Neustart der App, denn iOS stellt sie mit wieder her. + task.taskDescription = item.ratingKey + item.state = .downloading + save() + task.resume() + } + + private func task(for ratingKey: String) async -> URLSessionTask? { + await session.allTasks.first { $0.taskDescription == ratingKey } + } + + // MARK: - Rückmeldungen des Delegaten + + func handleProgress(ratingKey: String, received: Int64, expected: Int64) { + guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return } + item.receivedBytes = received + if expected > 0 { item.totalBytes = expected } + // Bewusst ohne `save()`: der Fortschritt kommt mehrmals je Sekunde, und + // jedes Schreiben wäre eine Plattenoperation. Für die Anzeige reicht + // die Beobachtung; gespeichert wird bei Zustandswechseln. + } + + func handleCompletion(ratingKey: String, temporaryFile: URL) { + guard let item = items.first(where: { $0.ratingKey == ratingKey }), + let sourceURL = URL(string: item.sourceURLString) + else { return } + + let name = fileStore.fileName(forRatingKey: ratingKey, sourceURL: sourceURL) + do { + _ = try fileStore.adopt(temporaryFile: temporaryFile, as: name) + item.localFileName = name + item.receivedBytes = fileStore.size(ofFileName: name) + item.totalBytes = item.receivedBytes + item.state = .completed + item.completedAt = Date() + save() + refreshUsage() + } catch { + fail(item, message: error.localizedDescription) + } + + Task { await startNextIfPossible() } + } + + func handleFailure(ratingKey: String, error: any Error) { + guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return } + + // Ein Abbruch durch den Nutzer ist kein Fehler. Ihn als solchen zu + // melden hieße, dass jedes Pausieren einen roten Hinweis erzeugt. + if (error as? URLError)?.code == .cancelled { + Task { await startNextIfPossible() } + return + } + + fail(item, message: error.localizedDescription) + Task { await startNextIfPossible() } + } + + private func fail(_ item: DownloadItem, message: String) { + item.state = .failed + item.failureMessage = message + save() + } + + // MARK: - Innereien + + private func reloadItems() { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.addedAt, order: .forward)] + ) + items = (try? context.fetch(descriptor)) ?? [] + } + + func refreshUsage() { + usedBytes = items.reduce(into: Int64(0)) { $0 += $1.occupiedBytes } + } + + private func save() { + try? context.save() + } +} + +/// Nimmt die Rückrufe der Hintergrundsitzung entgegen. +/// +/// Eine eigene Klasse, weil `URLSessionDelegate` von `NSObject` erben muss und +/// seine Rückrufe auf einer eigenen Warteschlange kommen. Sie reicht alles an +/// den Koordinator auf dem Hauptakteur weiter — dort liegen die Daten. +final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { + weak var coordinator: DownloadCoordinator? + + /// Wird von der App gesetzt, wenn iOS sie zum Abschluss von Downloads + /// aufgeweckt hat. Muss aufgerufen werden, sobald alles verarbeitet ist — + /// sonst beendet iOS die App hart und merkt sich das negativ. + var backgroundCompletionHandler: (@Sendable () -> Void)? + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + guard let key = downloadTask.taskDescription else { 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. + let temporary = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + try? FileManager.default.moveItem(at: location, to: temporary) + + Task { @MainActor [weak coordinator] in + coordinator?.handleCompletion(ratingKey: key, temporaryFile: temporary) + } + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + guard let key = downloadTask.taskDescription else { return } + Task { @MainActor [weak coordinator] in + coordinator?.handleProgress( + ratingKey: key, + received: totalBytesWritten, + expected: totalBytesExpectedToWrite + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + guard let error, let key = task.taskDescription else { return } + Task { @MainActor [weak coordinator] in + coordinator?.handleFailure(ratingKey: key, error: error) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + let handler = backgroundCompletionHandler + backgroundCompletionHandler = nil + Task { @MainActor in handler?() } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift new file mode 100644 index 0000000..3526e73 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Verwaltet die heruntergeladenen Dateien auf der Platte. +/// +/// Getrennt von der Datenbank, damit beides einzeln prüfbar bleibt: hier geht +/// es nur um Dateien, Pfade und Platz. +public struct DownloadFileStore: Sendable { + private let root: URL + + /// Nicht gespeichert, sondern bei jedem Zugriff frisch geholt. + /// `FileManager` ist nicht Sendable; `.default` ist aber + /// threadsicher für die hier verwendeten Operationen, und ein eigener + /// Verweis pro Aufruf kostet nichts. + private var fileManager: FileManager { .default } + + public init(root: URL? = nil) throws { + if let root { + self.root = root + } else { + // „Application Support", nicht „Caches": iOS räumt den Zwischen- + // speicher bei knappem Platz eigenmächtig leer. Ein Film, der auf + // dem Flug verschwindet, wäre das Gegenteil von Offline-Nutzung. + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + self.root = base.appending(path: "Downloads", directoryHint: .isDirectory) + } + try createRootIfNeeded() + } + + private func createRootIfNeeded() throws { + guard !fileManager.fileExists(atPath: root.path(percentEncoded: false)) else { return } + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + + // Ein 40-GB-Remux gehört nicht in die iCloud-Sicherung. Ohne diese + // Markierung zählt Apple ihn zum Speicherbedarf des Kontos — und die + // Sicherung selbst würde tagelang laufen. + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var mutableRoot = root + try mutableRoot.setResourceValues(resourceValues) + } + + public func url(forFileName name: String) -> URL { + root.appending(path: name) + } + + /// Ein Dateiname aus Titelschlüssel und ursprünglicher Endung. + /// + /// Der Schlüssel statt des Titels: Titel enthalten Schrägstriche, + /// Doppelpunkte und Umlaute, und die Endung entscheidet mit darüber, ob + /// AVFoundation die Datei überhaupt zu öffnen versucht. + public func fileName(forRatingKey key: String, sourceURL: URL) -> String { + let ext = sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension + return "\(key).\(ext)" + } + + /// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz. + /// + /// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der + /// Rückruf zurückkehrt. Ein Kopiervorgang für 40 GB wäre zudem ein zweites + /// Mal derselbe Platzbedarf. + public func adopt(temporaryFile: URL, as name: String) throws -> URL { + let destination = url(forFileName: name) + if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: temporaryFile, to: destination) + return destination + } + + public func remove(fileName: String?) { + guard let fileName else { return } + try? fileManager.removeItem(at: url(forFileName: fileName)) + } + + public func exists(fileName: String?) -> Bool { + guard let fileName else { return false } + return fileManager.fileExists(atPath: url(forFileName: fileName).path(percentEncoded: false)) + } + + public func size(ofFileName fileName: String) -> Int64 { + let attributes = try? fileManager.attributesOfItem( + atPath: url(forFileName: fileName).path(percentEncoded: false) + ) + return (attributes?[.size] as? NSNumber)?.int64Value ?? 0 + } + + /// Was die Downloads insgesamt belegen. + public func totalSize() -> Int64 { + guard let entries = try? fileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.fileSizeKey] + ) else { return 0 } + + return entries.reduce(into: Int64(0)) { total, entry in + let values = try? entry.resourceValues(forKeys: [.fileSizeKey]) + total += Int64(values?.fileSize ?? 0) + } + } + + /// Wie viel auf dem Gerät noch frei ist. + /// + /// Vor jedem Download geprüft: ein Download, der das Gerät vollschreibt, + /// legt auch alles andere lahm. + public func availableCapacity() -> Int64 { + let values = try? root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + return values?.volumeAvailableCapacityForImportantUsage ?? 0 + } + + /// Entfernt Dateien, zu denen es keinen Eintrag mehr gibt. + /// + /// Verwaiste Dateien entstehen, wenn die App während des Abschlusses + /// beendet wird: die Datei liegt schon da, der Eintrag ist noch nicht + /// aktualisiert. Ohne Aufräumen belegen sie dauerhaft Platz, ohne dass + /// die Downloads-Ansicht sie zeigt. + public func removeOrphans(keeping knownFileNames: Set) -> Int { + guard let entries = try? fileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil + ) else { return 0 } + + var removed = 0 + for entry in entries where !knownFileNames.contains(entry.lastPathComponent) { + if (try? fileManager.removeItem(at: entry)) != nil { removed += 1 } + } + return removed + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift new file mode 100644 index 0000000..05fb066 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift @@ -0,0 +1,161 @@ +import Foundation +import SwiftData + +/// Ein heruntergeladener oder herunterzuladender Titel. +/// +/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im +/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start +/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist. +@Model +public final class DownloadItem { + /// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel + /// soll nicht zweimal in der Warteschlange stehen. + @Attribute(.unique) public var ratingKey: String + + /// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern, + /// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und + /// ein unlesbarer Zustand hieße: laufende Downloads sind verloren. + public var stateRaw: String + + public var title: String + public var subtitle: String? + /// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die + /// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline". + public var seriesKey: String? + public var seriesTitle: String? + public var seasonKey: String? + public var seasonIndex: Int? + public var episodeIndex: Int? + + public var totalBytes: Int64 + public var receivedBytes: Int64 + + /// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der + /// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter + /// absoluter Pfad zeigt danach ins Leere. + public var localFileName: String? + public var artworkFileName: String? + + public var addedAt: Date + public var completedAt: Date? + /// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen. + public var watchedAt: Date? + public var failureMessage: String? + + /// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten + /// App-Container und nicht in den Voreinstellungen. + public var sourceURLString: String + public var durationMilliseconds: Int? + + /// Was der Player offline über die Datei wissen muss: Container, Codecs, + /// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und + /// sich eigene SwiftData-Modelle dafür nicht lohnen. + public var offlineMediaData: Data? + + /// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim + /// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null. + public var resumeData: Data? + + public init( + ratingKey: String, + title: String, + subtitle: String? = nil, + sourceURLString: String, + state: DownloadState = .queued, + totalBytes: Int64 = 0, + seriesKey: String? = nil, + seriesTitle: String? = nil, + seasonKey: String? = nil, + seasonIndex: Int? = nil, + episodeIndex: Int? = nil, + durationMilliseconds: Int? = nil, + offlineMediaData: Data? = nil + ) { + self.ratingKey = ratingKey + self.title = title + self.subtitle = subtitle + self.sourceURLString = sourceURLString + self.stateRaw = state.rawValue + self.totalBytes = totalBytes + self.receivedBytes = 0 + self.seriesKey = seriesKey + self.seriesTitle = seriesTitle + self.seasonKey = seasonKey + self.seasonIndex = seasonIndex + self.episodeIndex = episodeIndex + self.durationMilliseconds = durationMilliseconds + self.offlineMediaData = offlineMediaData + self.addedAt = Date() + } + + public var state: DownloadState { + get { DownloadState(rawValue: stateRaw) ?? .failed } + set { stateRaw = newValue.rawValue } + } + + /// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist. + /// + /// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein + /// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt + /// die ehrlichere Anzeige. + public var progress: Double? { + guard totalBytes > 0 else { return nil } + return min(Double(receivedBytes) / Double(totalBytes), 1) + } + + public var isComplete: Bool { state == .completed } + + /// Wie viel Platz dieser Eintrag gerade belegt. + public var occupiedBytes: Int64 { + state.occupiesStorage ? max(receivedBytes, 0) : 0 + } +} + +/// Was der Player offline über eine Datei wissen muss. +/// +/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich +/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs, +/// um zwischen den Motoren zu wählen. +public struct OfflineMedia: Sendable, Codable, Equatable { + public var container: String? + public var videoCodec: String? + public var audioCodec: String? + public var videoResolution: String? + public var bitrate: Int? + public var audioLanguages: [String] + public var subtitleLanguages: [String] + /// Intro und Abspann, in Millisekunden. + public var markers: [OfflineMarker] + + public init( + container: String? = nil, + videoCodec: String? = nil, + audioCodec: String? = nil, + videoResolution: String? = nil, + bitrate: Int? = nil, + audioLanguages: [String] = [], + subtitleLanguages: [String] = [], + markers: [OfflineMarker] = [] + ) { + self.container = container + self.videoCodec = videoCodec + self.audioCodec = audioCodec + self.videoResolution = videoResolution + self.bitrate = bitrate + self.audioLanguages = audioLanguages + self.subtitleLanguages = subtitleLanguages + self.markers = markers + } +} + +public struct OfflineMarker: Sendable, Codable, Equatable { + public var type: String + public var startMilliseconds: Int + public var endMilliseconds: Int + + public init(type: String, startMilliseconds: Int, endMilliseconds: Int) { + self.type = type + self.startMilliseconds = startMilliseconds + self.endMilliseconds = endMilliseconds + } +} diff --git a/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadCoordinatorTests.swift b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadCoordinatorTests.swift new file mode 100644 index 0000000..e83b17f --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadCoordinatorTests.swift @@ -0,0 +1,264 @@ +import Foundation +import SwiftData +import Testing +@testable import FlixyDownloads + +/// Baut einen Koordinator, der nichts überträgt. +/// +/// `maximumConcurrentDownloads = 0` hält die Warteschlange an: geprüft wird +/// die Buchführung — Annehmen, Ablehnen, Reihenfolge —, nicht das Netz. +@MainActor +private func makeCoordinator( + limitBytes: Int64? = nil, + root: URL +) throws -> DownloadCoordinator { + let container = try ModelContainer( + for: DownloadItem.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let defaults = UserDefaults(suiteName: "flixy.tests.\(UUID().uuidString)")! + + let coordinator = DownloadCoordinator( + container: container, + fileStore: try DownloadFileStore(root: root), + defaults: defaults, + sessionConfiguration: .ephemeral + ) + coordinator.maximumConcurrentDownloads = 0 + coordinator.storageLimitBytes = limitBytes + return coordinator +} + +private func plan( + _ key: String, + bytes: Int64 = 1_000, + series: String? = nil, + season: Int? = nil, + episode: Int? = nil +) -> DownloadPlan { + DownloadPlan( + ratingKey: key, + title: "Titel \(key)", + sourceURL: URL(string: "http://server:32400/library/parts/\(key)/file.mkv")!, + seriesKey: series, + seasonIndex: season, + episodeIndex: episode, + expectedBytes: bytes + ) +} + +private func temporaryRoot() -> URL { + FileManager.default.temporaryDirectory.appending(path: "flixy-tests-\(UUID().uuidString)") +} + +@MainActor +@Suite("Download-Warteschlange") +struct DownloadCoordinatorTests { + + @Test("Ein Titel wird angenommen und steht in der Warteschlange") + func acceptsFirstDownload() throws { + let coordinator = try makeCoordinator(root: temporaryRoot()) + let item = try coordinator.enqueue(plan("1")) + + #expect(item.state == .queued) + #expect(coordinator.items.count == 1) + } + + @Test("Derselbe Titel wird nicht zweimal angenommen") + func rejectsDuplicates() throws { + // Ohne diese Prüfung entstünde bei zweimaligem Tippen auf den + // Download-Knopf eine zweite Übertragung derselben Datei — doppelte + // Bandbreite, doppelter Platz. + let coordinator = try makeCoordinator(root: temporaryRoot()) + _ = try coordinator.enqueue(plan("1")) + + #expect(throws: DownloadRefusal.alreadyExists) { + try coordinator.enqueue(plan("1")) + } + #expect(coordinator.items.count == 1) + } + + @Test("Ein gescheiterter Download darf erneut in die Warteschlange") + func failedDownloadsCanBeRetried() throws { + let coordinator = try makeCoordinator(root: temporaryRoot()) + let item = try coordinator.enqueue(plan("1")) + item.state = .failed + item.failureMessage = "Verbindung verloren" + + let retried = try coordinator.enqueue(plan("1")) + #expect(retried.state == .queued) + #expect(retried.failureMessage == nil) + #expect(coordinator.items.count == 1) + } + + @Test("Das Speicherlimit lehnt ab, bevor etwas geladen wird") + func respectsStorageLimit() throws { + let coordinator = try makeCoordinator(limitBytes: 5_000, root: temporaryRoot()) + + let first = try coordinator.enqueue(plan("1", bytes: 3_000)) + // Fertige Downloads zählen zur Belegung; wartende noch nicht. + first.state = .completed + first.receivedBytes = 3_000 + + // Ohne die Belegung neu zu berechnen wüsste der Koordinator nichts + // vom ersten Download — dieser Aufruf bildet den Weg nach, den auch + // der Abschluss einer echten Übertragung nimmt. + coordinator.refreshUsage() + + #expect(throws: DownloadRefusal.self) { + try coordinator.enqueue(plan("2", bytes: 3_000)) + } + } + + @Test("Unterhalb des Limits wird weiter angenommen") + func acceptsWithinLimit() throws { + let coordinator = try makeCoordinator(limitBytes: 10_000, root: temporaryRoot()) + _ = try coordinator.enqueue(plan("1", bytes: 3_000)) + _ = try coordinator.enqueue(plan("2", bytes: 3_000)) + + #expect(coordinator.items.count == 2) + } + + @Test("Ein Stapel nimmt an, was geht, und meldet den Rest") + func batchAcceptsWhatItCan() throws { + // Bei einer Staffel, von der eine Folge schon offline ist, darf nicht + // der ganze Stapel scheitern. + let coordinator = try makeCoordinator(root: temporaryRoot()) + _ = try coordinator.enqueue(plan("2")) + + let result = coordinator.enqueueBatch([plan("1"), plan("2"), plan("3")]) + + #expect(result.accepted.count == 2) + #expect(result.refused.count == 1) + #expect(result.refused.first?.1 == .alreadyExists) + } + + @Test("Entfernen löscht Eintrag und Belegung") + func removeClearsEverything() throws { + let coordinator = try makeCoordinator(root: temporaryRoot()) + let item = try coordinator.enqueue(plan("1")) + + coordinator.remove(item) + #expect(coordinator.items.isEmpty) + #expect(coordinator.usedBytes == 0) + } + + @Test("Folgen einer Serie werden in Reihenfolge abgearbeitet") + func episodesKeepTheirOrder() throws { + // Sonst wäre bei einer Staffel Folge 7 vor Folge 1 fertig — und + // unterwegs nützt die siebte Folge wenig, wenn die erste fehlt. + let coordinator = try makeCoordinator(root: temporaryRoot()) + _ = try coordinator.enqueue(plan("c", series: "s1", season: 1, episode: 3)) + _ = try coordinator.enqueue(plan("a", series: "s1", season: 1, episode: 1)) + _ = try coordinator.enqueue(plan("b", series: "s1", season: 1, episode: 2)) + + let order = coordinator.transferOrder.map(\.ratingKey) + #expect(order == ["a", "b", "c"]) + } + + @Test("Staffeln werden vor Folgennummern sortiert") + func seasonsSortBeforeEpisodes() throws { + let coordinator = try makeCoordinator(root: temporaryRoot()) + _ = try coordinator.enqueue(plan("s2e1", series: "s1", season: 2, episode: 1)) + _ = try coordinator.enqueue(plan("s1e9", series: "s1", season: 1, episode: 9)) + + let order = coordinator.transferOrder.map(\.ratingKey) + #expect(order == ["s1e9", "s2e1"]) + } + + @Test("Als gesehen markieren vermerkt den Zeitpunkt") + func markWatchedRecordsTime() throws { + // Grundlage fürs automatische Löschen nach dem Ansehen. + let coordinator = try makeCoordinator(root: temporaryRoot()) + let item = try coordinator.enqueue(plan("1")) + #expect(item.watchedAt == nil) + + coordinator.markWatched(ratingKey: "1") + #expect(item.watchedAt != nil) + } +} + +@Suite("Download-Dateien") +struct DownloadFileStoreTests { + + @Test("Der Dateiname stammt vom Schlüssel, nicht vom Titel") + func fileNameUsesRatingKey() throws { + // Titel enthalten Schrägstriche, Doppelpunkte und Umlaute — als + // Dateiname wäre das eine Quelle schwer auffindbarer Fehler. + let store = try DownloadFileStore(root: temporaryRoot()) + let name = store.fileName( + forRatingKey: "45231", + sourceURL: URL(string: "http://server/parts/1/Blade Runner: 2049.mkv")! + ) + #expect(name == "45231.mkv") + } + + @Test("Ohne Endung in der Adresse wird mp4 angenommen") + func defaultsToMP4() throws { + let store = try DownloadFileStore(root: temporaryRoot()) + let name = store.fileName( + forRatingKey: "1", + sourceURL: URL(string: "http://server/library/parts/1/1699887654")! + ) + #expect(name == "1.mp4") + } + + @Test("Verwaiste Dateien werden entfernt, bekannte bleiben") + func removesOrphansOnly() throws { + // Verwaiste Dateien entstehen, wenn die App genau beim Abschluss + // beendet wird: die Datei liegt da, der Eintrag fehlt. + let root = temporaryRoot() + let store = try DownloadFileStore(root: root) + + for name in ["bekannt.mkv", "verwaist.mkv"] { + try Data("x".utf8).write(to: store.url(forFileName: name)) + } + + let removed = store.removeOrphans(keeping: ["bekannt.mkv"]) + #expect(removed == 1) + #expect(store.exists(fileName: "bekannt.mkv")) + #expect(!store.exists(fileName: "verwaist.mkv")) + } + + @Test("Die Gesamtgröße zählt alle Dateien") + func totalSizeSumsFiles() throws { + let root = temporaryRoot() + let store = try DownloadFileStore(root: root) + + try Data(repeating: 0, count: 100).write(to: store.url(forFileName: "a.mkv")) + try Data(repeating: 0, count: 250).write(to: store.url(forFileName: "b.mkv")) + + #expect(store.totalSize() == 350) + } + + @Test("Eine fertige Datei wird verschoben, nicht kopiert") + func adoptMovesTheFile() throws { + // Kopieren hieße, denselben Platzbedarf ein zweites Mal — bei 40 GB + // der Unterschied zwischen „geht" und „geht nicht". + let root = temporaryRoot() + let store = try DownloadFileStore(root: root) + + let temporary = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try Data(repeating: 0, count: 42).write(to: temporary) + + let destination = try store.adopt(temporaryFile: temporary, as: "ziel.mkv") + + #expect(FileManager.default.fileExists(atPath: destination.path(percentEncoded: false))) + #expect(!FileManager.default.fileExists(atPath: temporary.path(percentEncoded: false))) + #expect(store.size(ofFileName: "ziel.mkv") == 42) + } + + @Test("Eine bestehende Datei wird ersetzt") + func adoptReplacesExisting() throws { + // Beim Wiederholen eines Downloads liegt die halbe Datei schon da. + let root = temporaryRoot() + let store = try DownloadFileStore(root: root) + try Data(repeating: 0, count: 10).write(to: store.url(forFileName: "ziel.mkv")) + + let temporary = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try Data(repeating: 0, count: 99).write(to: temporary) + _ = try store.adopt(temporaryFile: temporary, as: "ziel.mkv") + + #expect(store.size(ofFileName: "ziel.mkv") == 99) + } +}