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>
162 lines
5.8 KiB
Swift
162 lines
5.8 KiB
Swift
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
|
|
}
|
|
}
|