From af23372997ae30da400025d0c3dc2e9e11466abf Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 11 Sep 2026 17:40:10 +0200 Subject: [PATCH] Phase 4 und Teile von Phase 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Resources/Localizable.xcstrings | 385 ++++++++++++++++++ Flixy/Views/Detail/ItemActionsMenu.swift | 94 +++++ Flixy/Views/Detail/MediaDetailView.swift | 7 + Flixy/Views/Library/LibraryView.swift | 29 ++ Flixy/Views/Settings/AppearanceView.swift | 144 +++++++ Flixy/Views/Settings/SettingsView.swift | 73 ++++ .../Engine/DownloadCoordinator.swift | 37 ++ .../FlixyDownloads/Engine/DownloadRules.swift | 86 ++++ .../Endpoints/PlexServer+Playback.swift | 53 +++ .../DownloadRulesTests.swift | 154 +++++++ 10 files changed, 1062 insertions(+) create mode 100644 Flixy/Views/Detail/ItemActionsMenu.swift create mode 100644 Flixy/Views/Settings/AppearanceView.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift create mode 100644 Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 4874438..b0c4eb9 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2577,6 +2577,391 @@ } } } + }, + "settings.storageLimit": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicherlimit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Storage limit" + } + } + } + }, + "settings.storageUnlimited": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbegrenzt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlimited" + } + } + } + }, + "settings.deleteWatched": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesehenes löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete watched" + } + } + } + }, + "settings.deleteNever": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nie" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" + } + } + } + }, + "settings.deleteImmediately": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sofort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Immediately" + } + } + } + }, + "settings.deleteAfterDay": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach einem Tag" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "After a day" + } + } + } + }, + "settings.deleteAfterWeek": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach einer Woche" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "After a week" + } + } + } + }, + "settings.keepUnwatched": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Folgen vorhalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep episodes ready" + } + } + } + }, + "settings.keepOff": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + } + } + }, + "settings.keepCount %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld ungesehene" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld unwatched" + } + } + } + }, + "settings.downloadsExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Flixy lädt fehlende Folgen nach, sobald du eine gesehen hast, und entfernt Gesehenes nach der eingestellten Frist." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Flixy downloads missing episodes once you finish one, and removes watched titles after the chosen delay." + } + } + } + }, + "appearance.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erscheinungsbild" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + } + } + }, + "appearance.accent": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akzentfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Accent color" + } + } + } + }, + "appearance.accentExplanation": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Farbe erscheint auf Knöpfen, Fortschrittsbalken und der ausgewählten Zeile in der Seitenleiste." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The color appears on buttons, progress bars and the selected row in the sidebar." + } + } + } + }, + "appearance.customColor": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigene Farbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom color" + } + } + } + }, + "appearance.hexPlaceholder": { + "comment": "Beispiel für eine Hex-Farbe", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "#1DB954" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "#1DB954" + } + } + } + }, + "appearance.invalidHex": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das ist keine gültige Farbe. Erwartet wird etwas wie #1DB954." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "That's not a valid color. Try something like #1DB954." + } + } + } + }, + "appearance.adjusted %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Farbe wurde auf %@ aufgehellt, damit sie auf dem dunklen Hintergrund lesbar bleibt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lightened to %@ so it stays readable on the dark background." + } + } + } + }, + "appearance.preview": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + } + } + }, + "action.markWatched": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Als gesehen markieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark as watched" + } + } + } + }, + "action.markUnwatched": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Als ungesehen markieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark as unwatched" + } + } + } + }, + "action.rate": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewerten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rate" + } + } + } + }, + "action.clearRating": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewertung entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear rating" + } + } + } + }, + "library.scan": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mediathek durchsuchen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan library" + } + } + } } }, "version": "1.1" diff --git a/Flixy/Views/Detail/ItemActionsMenu.swift b/Flixy/Views/Detail/ItemActionsMenu.swift new file mode 100644 index 0000000..0882ffc --- /dev/null +++ b/Flixy/Views/Detail/ItemActionsMenu.swift @@ -0,0 +1,94 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Das Aktionsmenü auf einer Detailseite. +/// +/// Alles, was den Server verändert, liegt hier zusammen — statt verstreut als +/// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig +/// braucht: Abspielen und Herunterladen. +struct ItemActionsMenu: View { + let item: PlexItem + /// Wird gerufen, wenn sich am Server etwas geändert hat und die + /// Detailseite neu geladen werden sollte. + let onChange: () async -> Void + + @Environment(FlixySession.self) private var session + @State private var isWorking = false + @State private var errorMessage: String? + + var body: some View { + Menu { + Button { + run { server in + if item.isWatched { + try await server.markUnwatched(ratingKey: item.ratingKey) + } else { + try await server.markWatched(ratingKey: item.ratingKey) + } + } + } label: { + Label( + item.isWatched ? "action.markUnwatched" : "action.markWatched", + systemImage: item.isWatched ? "eye.slash" : "checkmark.circle" + ) + } + + Menu { + ForEach((1...5).reversed(), id: \.self) { stars in + Button { + // Plex rechnet in Zehnteln: fünf Sterne sind eine 10. + run { try await $0.rate(ratingKey: item.ratingKey, rating: stars * 2) } + } label: { + Label(String(repeating: "★", count: stars), systemImage: "star") + } + } + if item.rating != nil { + Divider() + Button(role: .destructive) { + run { try await $0.rate(ratingKey: item.ratingKey, rating: 0) } + } label: { + Label("action.clearRating", systemImage: "star.slash") + } + } + } label: { + Label("action.rate", systemImage: "star") + } + } label: { + if isWorking { + ProgressView().controlSize(.small) + } else { + Image(systemName: "ellipsis.circle") + } + } + .disabled(isWorking) + .alert( + Text("error.title"), + isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) + ) { + Button("action.close") { errorMessage = nil } + } message: { + if let errorMessage { Text(errorMessage) } + } + } + + /// Führt eine Serveraktion aus und lädt danach neu. + /// + /// Bewusst ohne vorweggenommene Anzeige: den Haken sofort zu setzen und + /// bei einem Fehler wieder zurückzunehmen sieht aus wie ein Wackler. Die + /// Aktionen dauern Millisekunden. + private func run(_ action: @escaping (PlexServer) async throws -> Void) { + guard let server = session.server else { return } + isWorking = true + + Task { + defer { isWorking = false } + do { + try await action(server) + await onChange() + } catch { + errorMessage = PlexErrorText.describe(error) + } + } + } +} diff --git a/Flixy/Views/Detail/MediaDetailView.swift b/Flixy/Views/Detail/MediaDetailView.swift index 5bc52e1..3224170 100644 --- a/Flixy/Views/Detail/MediaDetailView.swift +++ b/Flixy/Views/Detail/MediaDetailView.swift @@ -40,6 +40,13 @@ struct MediaDetailView: View { .background(Color.flixyCanvas) .navigationTitle(state.value?.title ?? route.title) .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let item = state.value { + ToolbarItem(placement: .topBarTrailing) { + ItemActionsMenu(item: item) { await load() } + } + } + } .task(id: route.ratingKey) { await load() } } diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 664e489..447b1a7 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -15,6 +15,7 @@ struct LibraryView: View { @State private var page: PlexPage? @State private var isLoadingMore = false @State private var showsFilters = false + @State private var isScanning = false var body: some View { Group { @@ -141,9 +142,37 @@ struct LibraryView: View { .accessibilityLabel(Text("library.toggleLayout")) } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + Task { await scan() } + } label: { + Label("library.scan", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(isScanning) + } label: { + Image(systemName: "ellipsis.circle") + } + } + SearchToolbarItem() } + /// Stößt einen Suchlauf auf dem Server an. + /// + /// Der Server meldet nicht, wann er fertig ist — die Liste wird deshalb + /// nach kurzer Wartezeit neu geholt. Wer gerade Dateien abgelegt hat, + /// sieht sie damit meistens schon. + private func scan() async { + guard let server = session.server, !isScanning else { return } + isScanning = true + defer { isScanning = false } + + try? await server.refreshLibrary(library) + try? await Task.sleep(for: .seconds(3)) + await reload() + } + // MARK: - Laden private func reload() async { diff --git a/Flixy/Views/Settings/AppearanceView.swift b/Flixy/Views/Settings/AppearanceView.swift new file mode 100644 index 0000000..578db2f --- /dev/null +++ b/Flixy/Views/Settings/AppearanceView.swift @@ -0,0 +1,144 @@ +import SwiftUI +import FlixyDesign + +/// Die Farbeinstellungen. +/// +/// Kuratierte Kacheln plus eine freie Farbe. Die kuratierten sind so gewählt, +/// dass sie auf dem dunklen Grund ohne Nachhilfe lesbar sind; bei einer freien +/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe +/// stillschweigend zu verändern. +struct AppearanceView: View { + @Environment(FlixyTheme.self) private var theme + @State private var customHex: String = "" + @State private var showsCustomField = false + + private let columns = [GridItem(.adaptive(minimum: 64), spacing: FlixySpacing.regular)] + + var body: some View { + Form { + Section { + LazyVGrid(columns: columns, spacing: FlixySpacing.regular) { + ForEach(AccentOption.palette) { option in + swatch(option) + } + } + .padding(.vertical, FlixySpacing.snug) + } header: { + Text("appearance.accent") + } footer: { + Text("appearance.accentExplanation") + } + + Section { + Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy)) + + if showsCustomField { + HStack { + TextField("appearance.hexPlaceholder", text: $customHex) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .onSubmit(applyCustomColor) + + Button("action.apply", action: applyCustomColor) + .disabled(RGB(string: customHex) == nil) + } + + if !customHex.isEmpty, RGB(string: customHex) == nil { + Text("appearance.invalidHex") + .font(.flixyCaption) + .foregroundStyle(Color.flixyDestructive) + } + } + + if theme.accentWasAdjusted { + // Ohne diesen Hinweis wirkt die Korrektur wie ein Fehler: + // die gewählte Farbe sieht auf dem Bildschirm heller aus + // als im Farbwähler, und niemand wüsste warum. + Label { + Text("appearance.adjusted \(theme.accent.hexString)") + .font(.flixyCaption) + } icon: { + Image(systemName: "info.circle") + } + .foregroundStyle(Color.flixyTextSecondary) + } + } + + Section("appearance.preview") { + PreviewRow() + } + } + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("appearance.title")) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + if theme.selection.id == AccentOption.customIdentifier { + customHex = theme.selection.color.hexString + showsCustomField = true + } + } + } + + private func swatch(_ option: AccentOption) -> some View { + Button { + theme.select(option) + } label: { + Circle() + .fill(option.color.swiftUIColor) + .frame(height: 44) + .overlay { + if theme.selection == option { + Image(systemName: "checkmark") + .font(.headline) + // Dunkel auf der Farbe, nicht weiß: die Palette + // enthält helle Töne wie Amber, auf denen Weiß + // verschwinden würde. + .foregroundStyle(Color.flixyCanvas) + } + } + .overlay { + Circle() + .strokeBorder( + theme.selection == option ? Color.flixyTextPrimary : .clear, + lineWidth: 2 + ) + .padding(-4) + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text(option.name)) + .accessibilityAddTraits(theme.selection == option ? [.isButton, .isSelected] : .isButton) + } + + private func applyCustomColor() { + guard let color = RGB(string: customHex) else { return } + theme.select(.custom(color)) + } +} + +/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht. +private struct PreviewRow: View { + @Environment(\.flixyAccent) private var accent + + var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + HStack(spacing: FlixySpacing.regular) { + Label("hero.play", systemImage: "play.fill") + .font(.flixyCardTitle) + .padding(.horizontal, FlixySpacing.loose) + .padding(.vertical, FlixySpacing.snug) + .background(accent, in: .rect(cornerRadius: FlixyRadius.control)) + .foregroundStyle(Color.flixyCanvas) + + Label("sidebar.downloads", systemImage: "arrow.down.circle") + .font(.flixyCardTitle) + .foregroundStyle(accent) + } + + WatchProgressBar(fraction: 0.45) + } + .padding(.vertical, FlixySpacing.snug) + .listRowBackground(Color.flixySurface) + } +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 2e265ea..efa0488 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -1,5 +1,6 @@ import SwiftUI import FlixyDesign +import FlixyDownloads import FlixyPlayback import PlexKit @@ -11,6 +12,7 @@ import PlexKit struct SettingsView: View { @Environment(FlixySession.self) private var session @Environment(PlaybackSettings.self) private var settings + @Environment(DownloadCoordinator.self) private var downloads @State private var showsSignOutConfirmation = false var body: some View { @@ -54,6 +56,16 @@ struct SettingsView: View { Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext) } + Section { + NavigationLink { + AppearanceView() + } label: { + Label("appearance.title", systemImage: "paintpalette") + } + } + + downloadSection + Section("settings.app") { LabeledContent("settings.version", value: Branding.version) } @@ -80,6 +92,67 @@ struct SettingsView: View { } } + /// Die Download-Einstellungen. + /// + /// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit + /// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man + /// bewusst trifft — beim Löschen von Filmen soll niemand versehentlich + /// auf „sofort" rutschen. + @ViewBuilder + private var downloadSection: some View { + @Bindable var downloads = downloads + + Section { + Picker("settings.storageLimit", selection: storageLimitBinding) { + Text("settings.storageUnlimited").tag(Int64(0)) + ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in + Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000) + } + } + + Picker("settings.deleteWatched", selection: deleteAfterBinding) { + Text("settings.deleteNever").tag(Double(-1)) + Text("settings.deleteImmediately").tag(Double(0)) + Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds) + Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds) + } + + Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) { + Text("settings.keepOff").tag(0) + ForEach([1, 2, 3, 5], id: \.self) { count in + Text("settings.keepCount \(count)").tag(count) + } + } + + Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly) + } header: { + Text("sidebar.downloads") + } footer: { + Text("settings.downloadsExplanation") + } + } + + private var storageLimitBinding: Binding { + Binding( + get: { downloads.storageLimitBytes ?? 0 }, + set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil } + ) + } + + private var deleteAfterBinding: Binding { + Binding( + get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 }, + set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil } + ) + } + + private var keepUnwatchedBinding: Binding { + Binding( + get: { downloads.rules.keepUnwatchedEpisodes ?? 0 }, + set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil } + ) + } + /// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach /// Ruckeln ist das die erste Frage. private func connectionLabel(_ connection: PlexConnection) -> String { diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift index 5fb9cb9..7b81c4d 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift @@ -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. diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift new file mode 100644 index 0000000..22d3e54 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift @@ -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) +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index f222ca4..738e52b 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -189,3 +189,56 @@ extension PlexServer { .min(by: { ($0.index ?? 0) < ($1.index ?? 0) }) } } + +extension PlexServer { + /// Die Adresse für einen heruntergerechneten Download. + /// + /// **Noch nicht an einem echten Server geprüft.** Der Weg nutzt den + /// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Strom + /// statt HLS liefert — anders als der offizielle Sync-Weg von Plex, der + /// umständlich und schlecht dokumentiert ist. + /// + /// Zwei Dinge sind dabei offen und gehören am eigenen Server geprüft, + /// bevor die Einstellung angeboten wird: + /// 1. Ob Plex eine `Content-Length` mitschickt. Ohne sie gibt es keinen + /// Fortschrittsbalken und kein Fortsetzen nach dem Pausieren. + /// 2. Ob die entstehende Datei durchsuchbar ist. Ein Strom, der von vorn + /// gedacht ist, muss es nicht sein — dann ließe sich im Download nicht + /// springen. + /// + /// Bis das geklärt ist, bleibt der Download in Originalqualität die + /// Voreinstellung. + public nonisolated func transcodedDownloadURL( + ratingKey: String, + mediaIndex: Int = 0, + maximumBitrate: Int, + resolution: String? + ) -> URL? { + guard let baseURL = connection.url, + var components = URLComponents( + url: baseURL.appending(path: "/video/:/transcode/universal/start"), + resolvingAgainstBaseURL: false + ) + else { return nil } + + let request = TranscodeRequest( + ratingKey: ratingKey, + mediaIndex: mediaIndex, + maximumBitrate: maximumBitrate, + resolution: resolution, + // Ein Download ist kein Abspielen im Heimnetz: `wan` lässt Plex + // konservativer puffern, was für eine Datei egal ist. + isLocal: false + ) + + components.queryItems = request.queryItems + // Überschreibt `protocol=hls` aus der Anfrage: für eine Datei + // brauchen wir einen fortlaufenden Strom, keine Segmentliste. + .filter { $0.name != "protocol" && $0.name != "fastSeek" && $0.name != "copyts" } + + [ + URLQueryItem(name: "protocol", value: "http"), + URLQueryItem(name: "X-Plex-Token", value: token), + ] + return components.url + } +} diff --git a/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift new file mode 100644 index 0000000..88bf881 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadRulesTests.swift @@ -0,0 +1,154 @@ +import Foundation +import PlexKit +import Testing +@testable import FlixyDownloads + +/// Baut einen Eintrag im gewünschten Zustand, ohne Datenbank. +@MainActor +private func item( + _ key: String, + state: DownloadState = .completed, + watchedAgo: Duration? = nil, + series: String? = nil +) -> DownloadItem { + let item = DownloadItem( + ratingKey: key, + title: "Titel \(key)", + sourceURLString: "http://server/\(key)", + state: state, + seriesKey: series + ) + if let watchedAgo { + item.watchedAt = Date().addingTimeInterval(-watchedAgo.seconds) + } + return item +} + +@MainActor +@Suite("Download-Regeln") +struct DownloadRulesTests { + + // MARK: - Automatisches Löschen + + @Test("Ohne Regel wird nichts gelöscht") + func disabledRulesDeleteNothing() { + // Die wichtigste Zusicherung im ganzen Regelwerk: wer nichts + // eingestellt hat, verliert nichts. + let items = [item("1", watchedAgo: .afterOneWeek)] + #expect(DownloadRules.disabled.itemsToDelete(from: items).isEmpty) + } + + @Test("Ungesehene Titel werden nie gelöscht") + func unwatchedIsNeverDeleted() { + let rules = DownloadRules(deleteWatchedAfter: .immediately) + let items = [item("1"), item("2"), item("3")] + #expect(rules.itemsToDelete(from: items).isEmpty) + } + + @Test("Nach Ablauf der Frist wird gelöscht") + func deletesAfterGracePeriod() { + let rules = DownloadRules(deleteWatchedAfter: .afterOneDay) + let items = [ + item("alt", watchedAgo: .afterOneWeek), + item("frisch", watchedAgo: .seconds(60)), + ] + + let doomed = rules.itemsToDelete(from: items).map(\.ratingKey) + #expect(doomed == ["alt"]) + } + + @Test("Die Frist schützt frisch Gesehenes") + func gracePeriodProtectsRecent() { + // Wer eine Folge zu Ende sieht und gleich noch einmal die letzte + // Szene sucht, soll sie nicht schon verloren haben. + let rules = DownloadRules(deleteWatchedAfter: .afterOneDay) + let items = [item("1", watchedAgo: .seconds(3_600))] + #expect(rules.itemsToDelete(from: items).isEmpty) + } + + @Test("Unfertige Downloads werden nicht gelöscht") + func incompleteDownloadsSurvive() { + // Ein laufender Download hat nichts, was gesehen worden sein könnte — + // ein alter watchedAt-Wert aus einem früheren Durchlauf darf ihn + // nicht abräumen. + let rules = DownloadRules(deleteWatchedAfter: .immediately) + let running = item("1", state: .downloading, watchedAgo: .afterOneWeek) + #expect(rules.itemsToDelete(from: [running]).isEmpty) + } + + // MARK: - Vorrat an Folgen + + @Test("Ohne Regel wird nichts nachgeladen") + func noRefillWithoutRule() { + #expect(DownloadRules.disabled.seriesNeedingRefill(in: [item("1", series: "s")]).isEmpty) + } + + @Test("Ein leerer Vorrat verlangt die volle Zahl") + func emptyStockNeedsFullCount() { + let rules = DownloadRules(keepUnwatchedEpisodes: 3) + #expect(rules.missingEpisodeCount(forSeries: "s", in: []) == 3) + } + + @Test("Gesehene Folgen zählen nicht zum Vorrat") + func watchedEpisodesDoNotCount() { + // Sonst bliebe der Vorrat bei drei gesehenen Folgen stehen und es käme + // nie etwas Neues nach — genau das Gegenteil des Gewünschten. + let rules = DownloadRules(keepUnwatchedEpisodes: 3) + let items = [ + item("1", watchedAgo: .seconds(10), series: "s"), + item("2", watchedAgo: .seconds(10), series: "s"), + item("3", series: "s"), + ] + #expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 2) + } + + @Test("Wartende Downloads zählen zum Vorrat") + func queuedEpisodesCount() { + // Sonst würde bei jedem Durchlauf erneut nachgelegt, bis die ganze + // Serie auf dem Gerät liegt. + let rules = DownloadRules(keepUnwatchedEpisodes: 3) + let items = [ + item("1", state: .completed, series: "s"), + item("2", state: .queued, series: "s"), + item("3", state: .downloading, series: "s"), + ] + #expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 0) + } + + @Test("Gescheiterte Downloads zählen nicht zum Vorrat") + func failedEpisodesDoNotCount() { + let rules = DownloadRules(keepUnwatchedEpisodes: 2) + let items = [ + item("1", state: .completed, series: "s"), + item("2", state: .failed, series: "s"), + ] + #expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 1) + } + + @Test("Ein voller Vorrat verlangt nichts") + func fullStockNeedsNothing() { + let rules = DownloadRules(keepUnwatchedEpisodes: 2) + let items = [item("1", series: "s"), item("2", series: "s"), item("3", series: "s")] + #expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 0) + } + + @Test("Serien werden getrennt gezählt") + func seriesAreCountedSeparately() { + let rules = DownloadRules(keepUnwatchedEpisodes: 2) + let items = [ + item("a1", series: "a"), + item("a2", series: "a"), + item("b1", series: "b"), + ] + + let refill = rules.seriesNeedingRefill(in: items) + #expect(refill["a"] == nil) + #expect(refill["b"] == 1) + } + + @Test("Filme ohne Serie lösen kein Nachladen aus") + func moviesDoNotTriggerRefill() { + let rules = DownloadRules(keepUnwatchedEpisodes: 3) + #expect(rules.seriesNeedingRefill(in: [item("1"), item("2")]).isEmpty) + } +}