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:
@@ -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"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,13 @@ struct MediaDetailView: View {
|
|||||||
.background(Color.flixyCanvas)
|
.background(Color.flixyCanvas)
|
||||||
.navigationTitle(state.value?.title ?? route.title)
|
.navigationTitle(state.value?.title ?? route.title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
if let item = state.value {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
ItemActionsMenu(item: item) { await load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.task(id: route.ratingKey) { await load() }
|
.task(id: route.ratingKey) { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct LibraryView: View {
|
|||||||
@State private var page: PlexPage<PlexItem>?
|
@State private var page: PlexPage<PlexItem>?
|
||||||
@State private var isLoadingMore = false
|
@State private var isLoadingMore = false
|
||||||
@State private var showsFilters = false
|
@State private var showsFilters = false
|
||||||
|
@State private var isScanning = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -141,9 +142,37 @@ struct LibraryView: View {
|
|||||||
.accessibilityLabel(Text("library.toggleLayout"))
|
.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()
|
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
|
// MARK: - Laden
|
||||||
|
|
||||||
private func reload() async {
|
private func reload() async {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
|
import FlixyDownloads
|
||||||
import FlixyPlayback
|
import FlixyPlayback
|
||||||
import PlexKit
|
import PlexKit
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ import PlexKit
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -54,6 +56,16 @@ struct SettingsView: View {
|
|||||||
Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext)
|
Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
AppearanceView()
|
||||||
|
} label: {
|
||||||
|
Label("appearance.title", systemImage: "paintpalette")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadSection
|
||||||
|
|
||||||
Section("settings.app") {
|
Section("settings.app") {
|
||||||
LabeledContent("settings.version", value: Branding.version)
|
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<Int64> {
|
||||||
|
Binding(
|
||||||
|
get: { downloads.storageLimitBytes ?? 0 },
|
||||||
|
set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteAfterBinding: Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 },
|
||||||
|
set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var keepUnwatchedBinding: Binding<Int> {
|
||||||
|
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
|
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
||||||
/// Ruckeln ist das die erste Frage.
|
/// Ruckeln ist das die erste Frage.
|
||||||
private func connectionLabel(_ connection: PlexConnection) -> String {
|
private func connectionLabel(_ connection: PlexConnection) -> String {
|
||||||
|
|||||||
@@ -90,9 +90,19 @@ public final class DownloadCoordinator {
|
|||||||
/// eine fertige Folge mehr wert als zehn halbe.
|
/// eine fertige Folge mehr wert als zehn halbe.
|
||||||
public var maximumConcurrentDownloads = 2
|
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 {
|
private enum Key {
|
||||||
static let wifiOnly = "flixy.downloads.wifiOnly"
|
static let wifiOnly = "flixy.downloads.wifiOnly"
|
||||||
static let storageLimit = "flixy.downloads.storageLimit"
|
static let storageLimit = "flixy.downloads.storageLimit"
|
||||||
|
static let deleteAfter = "flixy.downloads.deleteWatchedAfter"
|
||||||
|
static let keepUnwatched = "flixy.downloads.keepUnwatched"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Container, nicht nur der Kontext.
|
/// Der Container, nicht nur der Kontext.
|
||||||
@@ -138,6 +148,16 @@ public final class DownloadCoordinator {
|
|||||||
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
|
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
|
||||||
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
|
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(
|
self.session = URLSession(
|
||||||
configuration: sessionConfiguration
|
configuration: sessionConfiguration
|
||||||
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
|
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
|
||||||
@@ -175,9 +195,22 @@ public final class DownloadCoordinator {
|
|||||||
|
|
||||||
save()
|
save()
|
||||||
refreshUsage()
|
refreshUsage()
|
||||||
|
applyRules()
|
||||||
await startNextIfPossible()
|
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
|
// MARK: - Hinzufügen
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -305,6 +338,10 @@ public final class DownloadCoordinator {
|
|||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||||
item.watchedAt = Date()
|
item.watchedAt = Date()
|
||||||
save()
|
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.
|
/// 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)
|
||||||
|
}
|
||||||
@@ -189,3 +189,56 @@ extension PlexServer {
|
|||||||
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user