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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user