Phase 3: Download-Engine und Offline-Wiedergabe
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 <noreply@anthropic.com>
This commit is contained in:
+63
-2
@@ -1,13 +1,20 @@
|
|||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
|
import FlixyDownloads
|
||||||
import FlixyPlayback
|
import FlixyPlayback
|
||||||
import PlexKit
|
import PlexKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FlixyApp: App {
|
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
|
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
|
||||||
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch die
|
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
|
||||||
/// eingestellte Farbe zurücksetzen.
|
/// Downloads zurücksetzen.
|
||||||
@State private var theme = FlixyTheme()
|
@State private var theme = FlixyTheme()
|
||||||
@State private var session: FlixySession
|
@State private var session: FlixySession
|
||||||
@State private var playback = PlaybackCoordinator()
|
@State private var playback = PlaybackCoordinator()
|
||||||
@@ -15,6 +22,9 @@ struct FlixyApp: App {
|
|||||||
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
||||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
|
private let modelContainer: ModelContainer
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let identity = PlexClientIdentity.current(
|
let identity = PlexClientIdentity.current(
|
||||||
@@ -25,6 +35,18 @@ struct FlixyApp: App {
|
|||||||
identity: identity,
|
identity: identity,
|
||||||
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
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 {
|
var body: some Scene {
|
||||||
@@ -34,6 +56,7 @@ struct FlixyApp: App {
|
|||||||
.environment(playback)
|
.environment(playback)
|
||||||
.environment(playbackSettings)
|
.environment(playbackSettings)
|
||||||
.environment(network)
|
.environment(network)
|
||||||
|
.environment(downloads)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
.flixyAccent(theme)
|
.flixyAccent(theme)
|
||||||
// Der eigentliche Schalter für „nur dunkel" steht in der
|
// 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.
|
// Absicherung für SwiftUI-Vorschauen, die sie nicht lesen.
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.tint(theme.accentColor)
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,8 @@ struct PlayableDetailView: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(Color.flixyTextSecondary)
|
.tint(Color.flixyTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ struct SeriesDetailView: View {
|
|||||||
height: 1200
|
height: 1200
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
EmptyView()
|
DownloadAllButton(container: item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,7 +131,7 @@ struct RootView: View {
|
|||||||
case .liveTV:
|
case .liveTV:
|
||||||
PlaceholderScreen(destination: .liveTV)
|
PlaceholderScreen(destination: .liveTV)
|
||||||
case .downloads:
|
case .downloads:
|
||||||
PlaceholderScreen(destination: .downloads)
|
DownloadsView()
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import FlixyDownloads
|
||||||
import FlixyPlayback
|
import FlixyPlayback
|
||||||
import FlixyVLC
|
import FlixyVLC
|
||||||
import PlexKit
|
import PlexKit
|
||||||
@@ -41,9 +42,13 @@ final class PlayerModel {
|
|||||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||||
private(set) var nextEpisode: PlexItem?
|
private(set) var nextEpisode: PlexItem?
|
||||||
private(set) var hasReachedEnd = false
|
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 request: PlaybackRequest
|
||||||
private let server: PlexServer?
|
private let server: PlexServer?
|
||||||
|
private let downloads: DownloadCoordinator
|
||||||
private let policy: PlaybackPolicy
|
private let policy: PlaybackPolicy
|
||||||
private let settings: PlaybackSettings
|
private let settings: PlaybackSettings
|
||||||
private let isLocalConnection: Bool
|
private let isLocalConnection: Bool
|
||||||
@@ -60,12 +65,14 @@ final class PlayerModel {
|
|||||||
init(
|
init(
|
||||||
request: PlaybackRequest,
|
request: PlaybackRequest,
|
||||||
server: PlexServer?,
|
server: PlexServer?,
|
||||||
|
downloads: DownloadCoordinator,
|
||||||
policy: PlaybackPolicy,
|
policy: PlaybackPolicy,
|
||||||
settings: PlaybackSettings,
|
settings: PlaybackSettings,
|
||||||
isLocalConnection: Bool
|
isLocalConnection: Bool
|
||||||
) {
|
) {
|
||||||
self.request = request
|
self.request = request
|
||||||
self.server = server
|
self.server = server
|
||||||
|
self.downloads = downloads
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.isLocalConnection = isLocalConnection
|
self.isLocalConnection = isLocalConnection
|
||||||
@@ -75,6 +82,14 @@ final class PlayerModel {
|
|||||||
// MARK: - Start
|
// MARK: - Start
|
||||||
|
|
||||||
func start() async {
|
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 {
|
guard let server else {
|
||||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||||
return
|
return
|
||||||
@@ -105,6 +120,59 @@ final class PlayerModel {
|
|||||||
return
|
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)
|
let engine = makeEngine(for: decision)
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
observe(engine)
|
observe(engine)
|
||||||
@@ -113,8 +181,7 @@ final class PlayerModel {
|
|||||||
try await engine.load(source)
|
try await engine.load(source)
|
||||||
engine.play()
|
engine.play()
|
||||||
phase = .playing
|
phase = .playing
|
||||||
startReporting()
|
if !isOffline { startReporting() }
|
||||||
preloadNextEpisode()
|
|
||||||
} catch {
|
} catch {
|
||||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
phase = .failed(message: String(localized: "player.loadFailed"))
|
||||||
}
|
}
|
||||||
@@ -314,6 +381,8 @@ final class PlayerModel {
|
|||||||
case .reachedEnd:
|
case .reachedEnd:
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
hasReachedEnd = true
|
hasReachedEnd = true
|
||||||
|
// Grundlage fürs automatische Löschen nach dem Ansehen.
|
||||||
|
downloads.markWatched(ratingKey: request.ratingKey)
|
||||||
case .tracksResolved(let audio, let subtitle):
|
case .tracksResolved(let audio, let subtitle):
|
||||||
audioTracks = audio
|
audioTracks = audio
|
||||||
subtitleTracks = subtitle
|
subtitleTracks = subtitle
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
|
import FlixyDownloads
|
||||||
import FlixyPlayback
|
import FlixyPlayback
|
||||||
import PlexKit
|
import PlexKit
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ struct PlayerView: View {
|
|||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
@State private var model: PlayerModel?
|
@State private var model: PlayerModel?
|
||||||
@@ -68,6 +70,7 @@ struct PlayerView: View {
|
|||||||
let model = PlayerModel(
|
let model = PlayerModel(
|
||||||
request: request,
|
request: request,
|
||||||
server: session.server,
|
server: session.server,
|
||||||
|
downloads: downloads,
|
||||||
policy: settings.policy(for: network),
|
policy: settings.policy(for: network),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
isLocalConnection: session.server?.connection.local ?? false
|
isLocalConnection: session.server?.connection.local ?? false
|
||||||
|
|||||||
@@ -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<DownloadItem>(
|
||||||
|
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?() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user