Files
Flaro/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift
T
GuidoandClaude Opus 5 62158303a2 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>
2026-09-11 17:34:57 +02:00

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
}
}