Phase 4 und Teile von Phase 5

Download-Regeln
- Automatisches Löschen nach dem Ansehen, mit Frist statt sofort: wer
  die letzte Szene noch einmal sucht, soll sie nicht schon verloren
  haben. „Nie" ist die Voreinstellung.
- „Halte N ungesehene Folgen offline": wartende und laufende Downloads
  zählen mit, sonst würde bei jedem Durchlauf nachgelegt, bis die ganze
  Serie auf dem Gerät liegt.
- Beides als reine Rechnung ohne Zugriff auf Datenbank oder Dateien —
  bei einer Regel, die Filme löscht, ist Prüfbarkeit keine Formsache.
  13 Tests.

Qualitätswahl beim Download
- transcodedDownloadURL über den Universal-Transcoder mit protocol=http
  statt über die umständliche Sync-API. NOCH NICHT AM SERVER GEPRÜFT:
  offen ist, ob Plex eine Content-Length mitschickt (sonst kein
  Fortschritt und kein Fortsetzen) und ob die Datei durchsuchbar ist.
  Steht so im Quelltext; Original bleibt Voreinstellung.

Erscheinungsbild
- Akzentfarbe: zehn kuratierte Kacheln plus freie Hex-Eingabe. Bei einer
  eigenen Farbe greift die Kontrastkorrektur und sagt es auch — sonst
  wirkt sie wie ein Fehler.
- Vorschaufläche mit Knopf, Symbol und Fortschrittsbalken.

Serveraktionen
- Gesehen-Status umschalten und Sterne setzen über ein Menü auf der
  Detailseite.
- Suchlauf der Mediathek aus der Werkzeugleiste.

115 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:40:10 +02:00
co-authored by Claude Opus 5
parent 62158303a2
commit af23372997
10 changed files with 1062 additions and 0 deletions
@@ -90,9 +90,19 @@ public final class DownloadCoordinator {
/// eine fertige Folge mehr wert als zehn halbe.
public var maximumConcurrentDownloads = 2
/// Die Regeln fürs automatische Löschen und Nachladen.
public var rules: DownloadRules {
didSet {
defaults.set(rules.deleteWatchedAfter?.seconds ?? -1, forKey: Key.deleteAfter)
defaults.set(rules.keepUnwatchedEpisodes ?? -1, forKey: Key.keepUnwatched)
}
}
private enum Key {
static let wifiOnly = "flixy.downloads.wifiOnly"
static let storageLimit = "flixy.downloads.storageLimit"
static let deleteAfter = "flixy.downloads.deleteWatchedAfter"
static let keepUnwatched = "flixy.downloads.keepUnwatched"
}
/// Der Container, nicht nur der Kontext.
@@ -138,6 +148,16 @@ public final class DownloadCoordinator {
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
// -1 steht für „abgeschaltet". Ein fehlender Schlüssel liefert 0, was
// bei der Löschfrist „sofort" hieße — der gefährlichste Wert als
// stille Voreinstellung.
let deleteAfter = defaults.object(forKey: Key.deleteAfter) as? Double ?? -1
let keepUnwatched = defaults.object(forKey: Key.keepUnwatched) as? Int ?? -1
self.rules = DownloadRules(
deleteWatchedAfter: deleteAfter >= 0 ? .seconds(deleteAfter) : nil,
keepUnwatchedEpisodes: keepUnwatched > 0 ? keepUnwatched : nil
)
self.session = URLSession(
configuration: sessionConfiguration
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
@@ -175,9 +195,22 @@ public final class DownloadCoordinator {
save()
refreshUsage()
applyRules()
await startNextIfPossible()
}
/// Wendet die Regeln auf den aktuellen Bestand an.
///
/// Nur das Löschen — das Nachladen braucht den Server und liegt deshalb
/// eine Schicht höher. Läuft beim Start und nach dem Ansehen, nicht auf
/// einem Zeitgeber: eine App, die im Hintergrund Filme löscht, wäre
/// schwer nachvollziehbar.
public func applyRules() {
let doomed = rules.itemsToDelete(from: items)
guard !doomed.isEmpty else { return }
for item in doomed { remove(item) }
}
// MARK: - Hinzufügen
@discardableResult
@@ -305,6 +338,10 @@ public final class DownloadCoordinator {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
item.watchedAt = Date()
save()
// Bei der Frist „sofort" wird der Titel hier bereits entfernt. Das ist
// gewollt: wer das einstellt, will nach dem Abspann keinen Platz mehr
// belegen.
applyRules()
}
/// Die lokale Datei eines fertigen Downloads.
@@ -0,0 +1,86 @@
import Foundation
import PlexKit
/// Die Regeln, nach denen Downloads von selbst entstehen und verschwinden.
///
/// Bewusst als reine Rechnung ohne Zugriff auf Datenbank, Dateien oder Netz:
/// sie bekommt den Bestand hereingereicht und gibt zurück, was zu tun wäre.
/// Dadurch lässt sich das Verhalten prüfen, ohne etwas zu löschen — und beim
/// automatischen Löschen von Filmen ist das keine akademische Vorsicht.
public struct DownloadRules: Sendable, Equatable {
/// Gesehene Titel nach dieser Frist entfernen. `nil` schaltet es ab.
///
/// Eine Frist statt sofortigem Löschen: wer eine Folge zu Ende sieht und
/// gleich noch einmal die letzte Szene sucht, soll sie nicht schon
/// verloren haben.
public var deleteWatchedAfter: Duration?
/// Wie viele ungesehene Folgen je Serie vorgehalten werden. `nil`
/// schaltet das Nachladen ab.
public var keepUnwatchedEpisodes: Int?
public init(deleteWatchedAfter: Duration? = nil, keepUnwatchedEpisodes: Int? = nil) {
self.deleteWatchedAfter = deleteWatchedAfter
self.keepUnwatchedEpisodes = keepUnwatchedEpisodes
}
public static let disabled = DownloadRules()
/// Welche Einträge nach den Regeln zu entfernen sind.
public func itemsToDelete(
from items: [DownloadItem],
now: Date = Date()
) -> [DownloadItem] {
guard let deleteWatchedAfter else { return [] }
return items.filter { item in
// Nur Fertiges: ein laufender Download hat nichts, was gesehen
// worden sein könnte.
guard item.state == .completed, let watchedAt = item.watchedAt else { return false }
return now.timeIntervalSince(watchedAt) >= deleteWatchedAfter.seconds
}
}
/// Wie viele Folgen je Serie noch fehlen, um den Vorrat zu füllen.
///
/// Gezählt wird, was offline und ungesehen ist. Gesehene Folgen zählen
/// nicht mit — sonst bliebe der Vorrat bei drei gesehenen Folgen stehen
/// und es käme nie etwas Neues nach.
public func missingEpisodeCount(
forSeries seriesKey: String,
in items: [DownloadItem]
) -> Int {
guard let keepUnwatchedEpisodes else { return 0 }
let unwatchedOffline = items.filter {
$0.seriesKey == seriesKey
&& $0.watchedAt == nil
// Wartende und laufende zählen mit: sonst würde bei jedem
// Aufruf erneut nachgelegt, bis die ganze Serie auf dem Gerät
// liegt.
&& $0.state != .failed
}.count
return max(0, keepUnwatchedEpisodes - unwatchedOffline)
}
/// Die Serien, für die nachgeladen werden müsste.
public func seriesNeedingRefill(in items: [DownloadItem]) -> [String: Int] {
guard keepUnwatchedEpisodes != nil else { return [:] }
let seriesKeys = Set(items.compactMap(\.seriesKey))
var result: [String: Int] = [:]
for key in seriesKeys {
let missing = missingEpisodeCount(forSeries: key, in: items)
if missing > 0 { result[key] = missing }
}
return result
}
}
extension Duration {
/// Gängige Fristen fürs automatische Löschen.
public static let immediately = Duration.zero
public static let afterOneDay = Duration.seconds(24 * 60 * 60)
public static let afterOneWeek = Duration.seconds(7 * 24 * 60 * 60)
}