From ea1327720c94bae4f9a63768a10ee9b7f44d7413 Mon Sep 17 00:00:00 2001 From: Pri Uppal Date: Wed, 11 Nov 2026 20:38:40 +1030 Subject: [PATCH] Add tag filtering and custom filters to Movies and Series Movies and Series can be filtered by a single tag from the filter menu, and by custom filters that work like the Radarr and Sonarr web interface's filters. - Custom filters from the server (/api/v3/customfilter, "movieIndex" and "series") are imported, matched client-side with the web interface's rules, and listed in the filter menu. Filters with conditions Ruddarr can't check are marked and keep those conditions untouched when saved. - Filters can be created and edited with a condition builder, and saved on the device or on the server, where the web interface shows them too. - Active filters show as chips above the grid; each chip clears its filter. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + Ruddarr.xcodeproj/project.pbxproj | 3 + Ruddarr/Dependencies/API/API+Live.swift | 24 + Ruddarr/Dependencies/API/API+Mock.swift | 15 + Ruddarr/Dependencies/API/API.swift | 6 + .../CustomFilters/CustomFilter+Matching.swift | 345 ++++++++++++++ .../Models/CustomFilters/CustomFilter.swift | 251 ++++++++++ .../CustomFilters/CustomFilterResource.swift | 24 + .../Models/CustomFilters/CustomFilters.swift | 185 ++++++++ .../CustomFilters/Movie+CustomFilter.swift | 91 ++++ .../CustomFilters/Series+CustomFilter.swift | 118 +++++ Ruddarr/Models/Instances/RadarrInstance.swift | 6 + Ruddarr/Models/Instances/SonarrInstance.swift | 6 + Ruddarr/Models/Movies/MovieSort.swift | 36 +- Ruddarr/Models/Movies/Movies.swift | 8 +- Ruddarr/Models/Series/SeriesModel.swift | 8 +- Ruddarr/Models/Series/SeriesSort.swift | 36 +- Ruddarr/Preview Content/custom-filters.json | 60 +++ Ruddarr/Views/Movies/MoviesView+Overlay.swift | 15 +- Ruddarr/Views/Movies/MoviesView+Toolbar.swift | 25 +- Ruddarr/Views/MoviesView.swift | 6 +- Ruddarr/Views/Series/SeriesView+Overlay.swift | 15 +- Ruddarr/Views/Series/SeriesView+Toolbar.swift | 25 +- Ruddarr/Views/SeriesView.swift | 6 +- .../CustomFilters/ActiveFilterChips.swift | 145 ++++++ .../CustomFilters/CustomFilter+Display.swift | 221 +++++++++ .../CustomFilterConditionHeader.swift | 33 ++ .../CustomFilterConditionSection.swift | 244 ++++++++++ .../CustomFilterConditionSummary.swift | 138 ++++++ .../CustomFilters/CustomFilterEditor.swift | 224 +++++++++ .../CustomFilterEditorSections.swift | 103 +++++ .../CustomFilterValueEditors.swift | 188 ++++++++ .../CustomFilters/CustomFiltersMenu.swift | 100 ++++ .../CustomFilters/CustomFiltersSheet.swift | 276 +++++++++++ Ruddarr/Views/Shared/Toolbar.swift | 3 + TestFlight/WhatToTest.en-US.txt | 2 + Tests/CustomFilterTests.swift | 427 ++++++++++++++++++ 37 files changed, 3386 insertions(+), 34 deletions(-) create mode 100644 Ruddarr/Models/CustomFilters/CustomFilter+Matching.swift create mode 100644 Ruddarr/Models/CustomFilters/CustomFilter.swift create mode 100644 Ruddarr/Models/CustomFilters/CustomFilterResource.swift create mode 100644 Ruddarr/Models/CustomFilters/CustomFilters.swift create mode 100644 Ruddarr/Models/CustomFilters/Movie+CustomFilter.swift create mode 100644 Ruddarr/Models/CustomFilters/Series+CustomFilter.swift create mode 100644 Ruddarr/Preview Content/custom-filters.json create mode 100644 Ruddarr/Views/Shared/CustomFilters/ActiveFilterChips.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFilter+Display.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionHeader.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSection.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSummary.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFilterEditor.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFilterEditorSections.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFilterValueEditors.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFiltersMenu.swift create mode 100644 Ruddarr/Views/Shared/CustomFilters/CustomFiltersSheet.swift create mode 100644 Tests/CustomFilterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e6053db9..e96d8705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support Sonarr multi-season packs - Improved x265, H.266, VC1 and VVC codec replacements - Open the exact movie on Letterboxd instead of a search +- Filter movies and series by tag +- Use custom filters from the Radarr and Sonarr web interface, or create your own ### Changed - Show filter, sort and instance buttons together on folded iPhone Duo diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 417b6659..1cd337dd 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -82,6 +82,9 @@ BBAD4DA72FF4A8FC0066994A /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Models/CustomFilters/CustomFilter.swift, + "Models/CustomFilters/CustomFilter+Matching.swift", + Models/CustomFilters/CustomFilterResource.swift, Services/NetworkInterfaces.swift, Services/NetworkSnapshot.swift, Services/ResolverRouting.swift, diff --git a/Ruddarr/Dependencies/API/API+Live.swift b/Ruddarr/Dependencies/API/API+Live.swift index 51e2dbf4..63510642 100644 --- a/Ruddarr/Dependencies/API/API+Live.swift +++ b/Ruddarr/Dependencies/API/API+Live.swift @@ -344,6 +344,30 @@ extension InstanceAPI { .appending(path: "/api/v3/notification") .appending(path: String(model.id ?? 0)) + return try await API.request(method: .delete, url: url, instance: instance) + }, customFilters: { instance in + let url = try await instance.baseURL() + .appending(path: "/api/v3/customfilter") + + // The endpoint returns every app screen's filters, so one odd entry mustn't fail the rest. + let filters: [LossyDecoded] = try await API.request(url: url, instance: instance) + return filters.compactMap(\.wrappedValue) + }, createCustomFilter: { model, instance in + let url = try await instance.baseURL() + .appending(path: "/api/v3/customfilter") + + return try await API.request(method: .post, url: url, body: model, instance: instance) + }, updateCustomFilter: { model, instance in + let url = try await instance.baseURL() + .appending(path: "/api/v3/customfilter") + .appending(path: String(model.id ?? 0)) + + return try await API.request(method: .put, url: url, body: model, instance: instance) + }, deleteCustomFilter: { model, instance in + let url = try await instance.baseURL() + .appending(path: "/api/v3/customfilter") + .appending(path: String(model.id ?? 0)) + return try await API.request(method: .delete, url: url, instance: instance) }) } diff --git a/Ruddarr/Dependencies/API/API+Mock.swift b/Ruddarr/Dependencies/API/API+Mock.swift index 5e6b97f4..d03085e1 100644 --- a/Ruddarr/Dependencies/API/API+Mock.swift +++ b/Ruddarr/Dependencies/API/API+Mock.swift @@ -215,6 +215,21 @@ extension InstanceAPI { }, deleteNotification: { _, _ in try await Task.sleep(for: .seconds(2)) + return API.Empty() + }, customFilters: { _ in + try await Task.sleep(for: .seconds(1)) + let filters: [CustomFilterResource] = loadPreviewData(filename: "custom-filters") + return filters + }, createCustomFilter: { model, _ in + try await Task.sleep(for: .seconds(1)) + var created = model + created.id = Int.random(in: 1_000...9_999) + return created + }, updateCustomFilter: { model, _ in + try await Task.sleep(for: .seconds(1)) + return model + }, deleteCustomFilter: { _, _ in + try await Task.sleep(for: .seconds(1)) return API.Empty() }) } diff --git a/Ruddarr/Dependencies/API/API.swift b/Ruddarr/Dependencies/API/API.swift index f4b7787e..2dccc347 100644 --- a/Ruddarr/Dependencies/API/API.swift +++ b/Ruddarr/Dependencies/API/API.swift @@ -70,6 +70,12 @@ struct InstanceAPI: Sendable { var createNotification: @Sendable (InstanceNotification, Instance) async throws -> InstanceNotification var updateNotification: @Sendable (InstanceNotification, Instance) async throws -> InstanceNotification var deleteNotification: @Sendable (InstanceNotification, Instance) async throws -> API.Empty + + /// Every custom filter on the server, of every `type`. Entries that fail to decode are dropped. + var customFilters: @Sendable (Instance) async throws -> [CustomFilterResource] + var createCustomFilter: @Sendable (CustomFilterResource, Instance) async throws -> CustomFilterResource + var updateCustomFilter: @Sendable (CustomFilterResource, Instance) async throws -> CustomFilterResource + var deleteCustomFilter: @Sendable (CustomFilterResource, Instance) async throws -> API.Empty } extension API { diff --git a/Ruddarr/Models/CustomFilters/CustomFilter+Matching.swift b/Ruddarr/Models/CustomFilters/CustomFilter+Matching.swift new file mode 100644 index 00000000..6ca0bf9e --- /dev/null +++ b/Ruddarr/Models/CustomFilters/CustomFilter+Matching.swift @@ -0,0 +1,345 @@ +import Foundation + +// Evaluates custom filters like the Radarr and Sonarr v4 web UI (`createClientSideCollectionSelector`, +// `filterTypePredicates` and `dateFilterPredicate`). Where the web UI would throw, such as calling a +// string operator on a number, the condition fails instead. +extension CustomFilter { + /// The conditions Ruddarr can't evaluate for `Item`: an unknown key or operator, or a value it can't read. + func unsupportedConditions(for item: Item.Type) -> [CustomFilterCondition] { + conditions.filter { !$0.isSupported(for: item) } + } + + /// Whether Ruddarr can evaluate every condition of this filter for `Item`. + func isSupported(for item: Item.Type) -> Bool { + unsupportedConditions(for: item).isEmpty + } + + /// Whether `item` passes every condition, evaluated like the Radarr/Sonarr web UI. + func matches(_ item: Item, now: Date = .now, calendar: Calendar = .current) -> Bool { + conditions.allSatisfy { $0.matches(item, now: now, calendar: calendar) } + } +} + +extension CustomFilterable { + /// The distinct, sorted string values of `key` across `items`, for `.libraryValues` fields. + static func libraryValues(for key: String, in items: [Self]) -> [String] { + var values: Set = [] + + for item in items { + switch item.customFilterValue(for: key) { + case .string(let value) where !value.isEmpty: values.insert(value) + case .strings(let list): values.formUnion(list.filter { !$0.isEmpty }) + default: break + } + } + + return values.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } +} + +extension CustomFilterCondition { + /// Whether Ruddarr can evaluate this condition for `Item`. + func isSupported(for item: Item.Type) -> Bool { + let field = Item.customFilterFields.first { $0.key == key } + + guard field != nil || Item.customFilterHiddenKeys.contains(key) else { return false } + guard let `operator` else { return false } + + if field?.builder == .date { + return value.elements.allSatisfy { $0.isReadableDate(for: `operator`) } + } + + // The web UI has no generic predicate for relative dates, so it would throw. + if `operator`.isRelativeDate { + return false + } + + return value.elements.allSatisfy(\.isScalar) + } + + /// Whether `item` passes this condition, following the web UI's filter loop. + func matches(_ item: Item, now: Date = .now, calendar: Calendar = .current) -> Bool { + let itemValue = item.customFilterValue(for: key) + + // An array value needs every element to pass for `notContains`/`notEqual`, otherwise any one. + let isNegative = type == CustomFilterOperator.notContains.rawValue || type == CustomFilterOperator.notEqual.rawValue + + switch itemValue { + case .unsupported, .missing: + return false + case .result(let result): + // The predicate ignores the value and operator, but still runs once per array element. + guard case .array(let values) = value else { return result } + return isNegative ? (values.isEmpty || result) : (!values.isEmpty && result) + default: + break + } + + guard let `operator` else { return false } + + let gregorian = calendar.identifier == .gregorian ? calendar : Calendar.gregorian(in: calendar.timeZone) + + func test(_ filterValue: CustomFilterValue) -> Bool { + itemValue.passes(filterValue, `operator`, now: now, calendar: gregorian) + } + + guard case .array(let values) = value else { + return test(value) + } + + return isNegative ? values.allSatisfy(test) : values.contains(where: test) + } +} + +extension CustomFilterOperator { + /// `inLast`, `notInLast`, `inNext` and `notInNext`, which take a `{ time, value }` object. + var isRelativeDate: Bool { + switch self { + case .inLast, .notInLast, .inNext, .notInNext: true + default: false + } + } +} + +/// A relative date value, `{ "time": "days", "value": 30 }`. A `null` or absent `value` means 0. +struct CustomFilterRelativeDate: Hashable, Sendable { + let unit: CustomFilterTimeUnit + let amount: Double + + init(unit: CustomFilterTimeUnit, amount: Double) { + self.unit = unit + self.amount = amount + } + + init?(_ value: CustomFilterValue) { + guard case .object(let object) = value, + case .string(let time) = object["time"], + let unit = CustomFilterTimeUnit(rawValue: time) + else { return nil } + + switch object["value"] { + case nil, .null: amount = 0 + case .int(let number): amount = Double(number) + case .double(let number) where number.isFinite: amount = number + default: return nil + } + + self.unit = unit + } + + var value: CustomFilterValue { + let number: CustomFilterValue = amount.rounded() == amount && abs(amount) < 1e15 + ? .int(Int(amount)) + : .double(amount) + + return .object(["time": .string(unit.rawValue), "value": number]) + } + + /// `now` moved by `amount` units (negated when `forward` is false), like `moment().add()`: + /// seconds to hours are exact, days, weeks and months are rounded and calendar-aware. + func date(from now: Date, forward: Bool, calendar: Calendar) -> Date? { + let amount = forward ? amount : -amount + + switch unit { + case .seconds: return now.addingTimeInterval(amount) + case .minutes: return now.addingTimeInterval(amount * 60) + case .hours: return now.addingTimeInterval(amount * 3_600) + case .days: return Self.add(.day, amount, to: now, calendar) + case .weeks: return Self.add(.day, amount * 7, to: now, calendar) + case .months: return Self.add(.month, amount, to: now, calendar) + } + } + + private static func add(_ component: Calendar.Component, _ amount: Double, to date: Date, _ calendar: Calendar) -> Date? { + // moment rounds day and month offsets half away from zero. + let rounded = amount.rounded(.toNearestOrAwayFromZero) + guard abs(rounded) < 1_000_000 else { return nil } + + return calendar.date(byAdding: component, value: Int(rounded), to: date) + } +} + +extension CustomFilterValue { + /// The elements of an array value, or the value itself. + fileprivate var elements: [CustomFilterValue] { + if case .array(let values) = self { return values } + return [self] + } + + fileprivate var isScalar: Bool { + switch self { + case .null, .bool, .int, .double, .string: true + case .array, .object: false + } + } + + fileprivate func isReadableDate(for operator: CustomFilterOperator) -> Bool { + switch `operator` { + case .lessThan, .greaterThan: localMidnight(in: .gregorian(in: .gmt)) != nil + case .inLast, .notInLast, .inNext, .notInNext: CustomFilterRelativeDate(self) != nil + // `dateFilterPredicate` returns false for every other operator. + default: true + } + } + + /// Local midnight of a `"yyyy-mm-dd"` string, as `moment("2024-01-31")` parses it. + func localMidnight(in calendar: Calendar) -> Date? { + guard case .string(let string) = self, + let match = string.wholeMatch(of: /(\d{4})-(\d{2})-(\d{2})/), + let year = Int(match.1), let month = Int(match.2), let day = Int(match.3) + else { return nil } + + let components = DateComponents(year: year, month: month, day: day) + guard components.isValidDate(in: calendar) else { return nil } + + return calendar.date(from: components) + } + + /// The value as JS `Number()` would coerce it for `<`/`>`, or `nil` for NaN. + fileprivate var numericValue: Double? { + switch self { + case .null: 0 + case .bool(let value): value ? 1 : 0 + case .int(let value): Double(value) + case .double(let value): value + case .string(let value): value.numericValue + case .array, .object: nil + } + } +} + +extension CustomFilterItemValue { + // The generic `filterTypePredicates`, or `dateFilterPredicate` for dates. + // swiftlint:disable:next cyclomatic_complexity + fileprivate func passes(_ filterValue: CustomFilterValue, _ operator: CustomFilterOperator, now: Date, calendar: Calendar) -> Bool { + if case .date(let date) = self { + return Self.date(date, passes: filterValue, `operator`, now: now, calendar: calendar) + } + + return switch `operator` { + case .equal: strictlyEquals(filterValue) + case .notEqual: !strictlyEquals(filterValue) + case .contains: contains(filterValue) ?? false + case .notContains: contains(filterValue).map { !$0 } ?? false + case .startsWith: hasAffix(filterValue, backwards: false) ?? false + case .notStartsWith: hasAffix(filterValue, backwards: false).map { !$0 } ?? false + case .endsWith: hasAffix(filterValue, backwards: true) ?? false + case .notEndsWith: hasAffix(filterValue, backwards: true).map { !$0 } ?? false + case .greaterThan: compare(to: filterValue) == .orderedDescending + case .greaterThanOrEqual: compare(to: filterValue).map { $0 != .orderedAscending } ?? false + case .lessThan: compare(to: filterValue) == .orderedAscending + case .lessThanOrEqual: compare(to: filterValue).map { $0 != .orderedDescending } ?? false + case .inLast, .notInLast, .inNext, .notInNext: false + } + } + + /// JS `===`: no type coercion, case-sensitive strings, and arrays never equal a value. + private func strictlyEquals(_ filterValue: CustomFilterValue) -> Bool { + switch (self, filterValue) { + case (.bool(let lhs), .bool(let rhs)): lhs == rhs + case (.number(let lhs), .int(let rhs)): lhs == Double(rhs) + case (.number(let lhs), .double(let rhs)): lhs == rhs + case (.string(let lhs), .string(let rhs)): lhs.utf16.elementsEqual(rhs.utf16) + default: false + } + } + + /// Strict membership for arrays, a case-insensitive substring test for strings, otherwise `nil` (the web UI throws). + private func contains(_ filterValue: CustomFilterValue) -> Bool? { + switch self { + case .numbers(let list): + return list.contains { Self.number($0).strictlyEquals(filterValue) } + case .strings(let list): + return list.contains { Self.string($0).strictlyEquals(filterValue) } + case .string(let string): + guard case .string(let needle) = filterValue else { return nil } + return needle.isEmpty || string.lowercased().range(of: needle.lowercased(), options: .literal) != nil + default: + return nil + } + } + + /// A case-insensitive prefix or suffix test on strings, otherwise `nil` (the web UI throws). + private func hasAffix(_ filterValue: CustomFilterValue, backwards: Bool) -> Bool? { + guard case .string(let string) = self, case .string(let affix) = filterValue else { return nil } + guard !affix.isEmpty else { return true } + + let options: String.CompareOptions = backwards ? [.literal, .anchored, .backwards] : [.literal, .anchored] + + return string.lowercased().range(of: affix.lowercased(), options: options) != nil + } + + /// JS relational comparison: strings by UTF-16 code units, everything else as numbers. `nil` for NaN. + private func compare(to filterValue: CustomFilterValue) -> ComparisonResult? { + if case .string(let lhs) = self, case .string(let rhs) = filterValue { + let left = Array(lhs.utf16), right = Array(rhs.utf16) + if left == right { return .orderedSame } + return left.lexicographicallyPrecedes(right) ? .orderedAscending : .orderedDescending + } + + let lhs: Double? = switch self { + case .bool(let value): value ? 1 : 0 + case .number(let value): value + case .string(let value): value.numericValue + default: nil + } + + guard let lhs, let rhs = filterValue.numericValue, !lhs.isNaN, !rhs.isNaN else { return nil } + + if lhs < rhs { return .orderedAscending } + if lhs > rhs { return .orderedDescending } + return .orderedSame + } + + /// `dateFilterPredicate`: a missing date never matches, and the bounds are strict. + private static func date( + _ date: Date?, passes filterValue: CustomFilterValue, _ operator: CustomFilterOperator, now: Date, calendar: Calendar + ) -> Bool { + guard let date else { return false } + + switch `operator` { + case .lessThan, .greaterThan: + guard let bound = filterValue.localMidnight(in: calendar) else { return false } + return `operator` == .lessThan ? date < bound : date > bound + case .inLast, .notInLast, .inNext, .notInNext: + guard let relative = CustomFilterRelativeDate(filterValue), + let bound = relative.date(from: now, forward: `operator` == .inNext || `operator` == .notInNext, calendar: calendar) + else { return false } + + return switch `operator` { + case .inLast: date > bound && date < now + case .notInLast: date < bound + case .inNext: date > now && date < bound + default: date > bound + } + default: + return false + } + } +} + +extension String { + /// JS `Number(string)`: whitespace is trimmed and an empty string is 0. + fileprivate var numericValue: Double? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return 0 } + return Double(trimmed) + } +} + +extension Calendar { + /// moment uses Gregorian dates in the device's time zone, whatever the user's calendar. + static func gregorian(in timeZone: TimeZone) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } +} + +extension CustomFilterItemValue { + /// A `Float` from the API as the number its JSON text was, so `7.3` stays `7.3` rather than `7.300000190734863`, + /// multiplied by `scale` like the web UI's ×10 rating predicates. + static func decimal(_ value: Float, scale: Double = 1) -> Self { + .number((Double(String(value)) ?? Double(value)) * scale) + } +} diff --git a/Ruddarr/Models/CustomFilters/CustomFilter.swift b/Ruddarr/Models/CustomFilters/CustomFilter.swift new file mode 100644 index 00000000..cf25ef96 --- /dev/null +++ b/Ruddarr/Models/CustomFilters/CustomFilter.swift @@ -0,0 +1,251 @@ +import Foundation + +/// A saved filter for the Movies or Series grid. +/// +/// Uses the same conditions the Radarr/Sonarr web UI stores at `/api/v3/customfilter`, so a filter +/// can live in the app only (`.app`) or on the server (`.server`), where the web UI shows it too. +struct CustomFilter: Identifiable, Hashable, Codable, Sendable { + var id: Identifier + var label: String + var conditions: [CustomFilterCondition] + + enum Identifier: Hashable, Codable, Sendable { + /// Saved in the app, on this device. + case app(UUID) + /// Saved on the Radarr/Sonarr server, with the server's id. + case server(Int) + } + + var isOnServer: Bool { + if case .server = id { return true } + return false + } +} + +/// One row of a custom filter: `{ "key": …, "value": …, "type": … }` on the wire. +/// +/// `type` is the operator's raw string. It is kept as a string so operators Ruddarr doesn't know +/// round-trip unchanged when a filter is saved again. A row without `type` means `equal`. +struct CustomFilterCondition: Hashable, Codable, Sendable { + var key: String + var value: CustomFilterValue + var type: String + + init(key: String, value: CustomFilterValue, type: String) { + self.key = key + self.value = value + self.type = type + } + + init(key: String, value: CustomFilterValue, operator: CustomFilterOperator) { + self.init(key: key, value: value, type: `operator`.rawValue) + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + value = try container.decodeIfPresent(CustomFilterValue.self, forKey: .value) ?? .null + type = try container.decodeIfPresent(String.self, forKey: .type) ?? CustomFilterOperator.equal.rawValue + } + + /// `nil` when `type` is an operator Ruddarr doesn't know. + var `operator`: CustomFilterOperator? { + CustomFilterOperator(rawValue: type) + } +} + +/// Any JSON value, as stored in a condition's `value`. +/// +/// Builder-made filters store an array (`[2, 5]`, `["released"]`, `[true]`), except dates, which store +/// a `"yyyy-mm-dd"` string or a `{ "time": "days", "value": 30 }` object. Integers decode as `.int` +/// so tag and profile ids stay integers when the filter is saved back to the server. +enum CustomFilterValue: Hashable, Codable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([CustomFilterValue]) + case object([String: CustomFilterValue]) + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([CustomFilterValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: CustomFilterValue].self)) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .int(let value): try container.encode(value) + case .double(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} + +/// The operators of the web UI's filter builder (`filterTypes`). +enum CustomFilterOperator: String, CaseIterable, Identifiable, Sendable { + var id: Self { self } + + case contains + case notContains + case equal + case notEqual + case greaterThan + case greaterThanOrEqual + case lessThan + case lessThanOrEqual + case startsWith + case notStartsWith + case endsWith + case notEndsWith + case inLast + case notInLast + case inNext + case notInNext +} + +/// How the web UI's builder treats a field (`filterBuilderTypes`). Decides the operators offered. +enum CustomFilterBuilderType: Sendable { + case array + case contains + case date + case equal + case exact + case number + case string + + /// The operators the web UI offers for this builder type, in its order. The first is the default. + var operators: [CustomFilterOperator] { + switch self { + case .array: [.contains, .notContains] + case .contains: [.contains] + case .date: [.lessThan, .greaterThan, .inLast, .notInLast, .inNext, .notInNext] + case .equal: [.equal] + case .exact: [.equal, .notEqual] + case .number: [.equal, .greaterThan, .greaterThanOrEqual, .lessThan, .lessThanOrEqual, .notEqual] + case .string: [.contains, .notContains, .equal, .notEqual, .startsWith, .notStartsWith, .endsWith, .notEndsWith] + } + } +} + +/// What kind of value a field takes, which decides the value editor in the builder. +enum CustomFilterValueKind: Hashable, Sendable { + /// Free-form strings. + case text + /// Free-form numbers. + case number + /// A size in bytes, entered with a unit. + case bytes + /// A `"yyyy-mm-dd"` string for before/after, or a `{ time, value }` object for in/not in last/next. + case date + /// `true` or `false`. + case bool + /// The instance's tag ids. + case tag + /// The instance's quality profile ids. + case qualityProfile + /// A fixed list of values, such as statuses. + case options([CustomFilterOption]) + /// The distinct values of this field found in the library, such as genres or studios. + case libraryValues +} + +struct CustomFilterOption: Hashable, Identifiable, Sendable { + let value: CustomFilterValue + let label: String + + var id: CustomFilterValue { value } +} + +/// A field the builder offers, such as `tags` or `added`. +struct CustomFilterField: Identifiable, Sendable { + let key: String + let label: String + let builder: CustomFilterBuilderType + let kind: CustomFilterValueKind + /// The section the builder lists this field in. + var group: CustomFilterFieldGroup = .metadata + + var id: String { key } +} + +/// The sections of the builder's field list, in display order. +enum CustomFilterFieldGroup: CaseIterable, Identifiable, Sendable { + var id: Self { self } + + case status + case library + case dates + case ratings + case metadata +} + +/// The units of a relative date value, `{ "time": , "value": }`. The web UI has no years. +enum CustomFilterTimeUnit: String, CaseIterable, Identifiable, Sendable { + var id: Self { self } + + case seconds + case minutes + case hours + case days + case weeks + case months +} + +/// The value an item has for a filter key, ready for comparison. +enum CustomFilterItemValue: Sendable { + /// Ruddarr can't evaluate this key. + case unsupported + /// The item has no value for this key; the web UI rejects the item. + case missing + case bool(Bool) + case number(Double) + case string(String) + case numbers([Double]) + case strings([String]) + /// A date; `nil` never matches, as in the web UI. + case date(Date?) + /// A key whose web UI predicate ignores the value and operator, such as Sonarr's `missing`. + case result(Bool) +} + +/// A library item that custom filters can match: `Movie` and `Series`. +protocol CustomFilterable: Sendable { + /// The server's `type` for this grid's custom filters: `movieIndex` in Radarr, `series` in Sonarr. + static var customFilterType: String { get } + + /// The fields the builder offers, in the web UI's order. Only fields Ruddarr can evaluate. + static var customFilterFields: [CustomFilterField] { get } + + /// Keys the builder doesn't offer but the web UI can still evaluate, such as Radarr's `hasFile`, + /// which its predefined filters use. Conditions with these keys are supported. + static var customFilterHiddenKeys: Set { get } + + /// The item's value for `key`, following the web UI's predicate for that key. + func customFilterValue(for key: String) -> CustomFilterItemValue +} + +extension CustomFilterable { + static var customFilterHiddenKeys: Set { [] } +} diff --git a/Ruddarr/Models/CustomFilters/CustomFilterResource.swift b/Ruddarr/Models/CustomFilters/CustomFilterResource.swift new file mode 100644 index 00000000..275bc7fa --- /dev/null +++ b/Ruddarr/Models/CustomFilters/CustomFilterResource.swift @@ -0,0 +1,24 @@ +import Foundation + +/// A custom filter as `/api/v3/customfilter` sends and takes it. `id` is omitted when creating one. +struct CustomFilterResource: Codable, Equatable, Sendable { + var id: Int? + var type: String + var label: String + var filters: [CustomFilterCondition] + + /// `filter` for the server, without an `id` unless it already lives there. + init(_ filter: CustomFilter, type: String) { + if case .server(let id) = filter.id { self.id = id } + + self.type = type + self.label = filter.label + self.filters = filter.conditions + } + + /// The filter, or `nil` when the server didn't send an `id`. + var customFilter: CustomFilter? { + guard let id else { return nil } + return CustomFilter(id: .server(id), label: label, conditions: filters) + } +} diff --git a/Ruddarr/Models/CustomFilters/CustomFilters.swift b/Ruddarr/Models/CustomFilters/CustomFilters.swift new file mode 100644 index 00000000..29062a07 --- /dev/null +++ b/Ruddarr/Models/CustomFilters/CustomFilters.swift @@ -0,0 +1,185 @@ +import Foundation +import Sentry + +/// The custom filters of one instance's Movies or Series grid: the ones saved in the app and the ones on the server. +/// +/// Both lists are cached per instance and `type` in `dependencies.store`, and loaded in `init`, +/// so a selected filter applies on a cold launch before (or without) a network round trip. +@MainActor +@Observable +final class CustomFilters { + private let instance: Instance + private let type: String + + /// Filters saved in the app for this instance, sorted by label. + private(set) var appFilters: [CustomFilter] = [] + /// Filters saved on the server with this grid's `type`, as last fetched (cached for offline use), sorted by label. + private(set) var serverFilters: [CustomFilter] = [] + + var error: API.Error? + var isWorking: Bool = false + + /// `type` is the server's custom filter type, e.g. `Movie.customFilterType`. + init(_ instance: Instance, type: String) { + self.instance = instance + self.type = type + + guard !isVoid, let data = dependencies.store.data(forKey: storeKey) else { return } + + do { + let cache = try JSONDecoder().decode(Cache.self, from: data) + appFilters = Self.sorted(cache.app) + serverFilters = Self.sorted(cache.server) + } catch { + leaveBreadcrumb(.warning, category: "custom.filters", message: "Cache decode failed", data: ["error": error]) + } + } + + /// Server filters first, then app filters, each sorted by label. + var all: [CustomFilter] { + serverFilters + appFilters + } + + func byId(_ id: CustomFilter.ID?) -> CustomFilter? { + guard let id else { return nil } + return all.first { $0.id == id } + } + + /// Fetches the server's filters of this grid's `type`. + /// Background fetches only leave a breadcrumb when they fail, so they don't set `error`. + func fetch(reportingErrors: Bool = false) async -> Bool { + guard !isVoid else { return true } + + return await request("fetch", reportingErrors: reportingErrors) { + let resources = try await dependencies.api.instance.customFilters(instance) + serverFilters = Self.sorted(resources.filter { $0.type == type }.compactMap(\.customFilter)) + persist() + } + } + + /// Saves `filter` on the server when `onServer` is true, otherwise in the app. + /// Moving a filter between the two deletes the old copy. Returns the saved filter, whose `id` changes when it moves. + func save(_ filter: CustomFilter, onServer: Bool) async -> CustomFilter? { + var saved: CustomFilter? + let resource = CustomFilterResource(filter, type: type) + + let success = await request("save") { + switch (filter.id, onServer) { + case (.app, false): + saved = filter + case (.app, true): + let created = try await dependencies.api.instance.createCustomFilter(resource, instance) + guard let result = created.customFilter else { + throw API.Error(from: AppError("The server didn't return the saved filter's id.")) + } + + appFilters.removeAll { $0.id == filter.id } + saved = result + case (.server, true): + let updated = try await dependencies.api.instance.updateCustomFilter(resource, instance) + saved = updated.customFilter ?? filter + case (.server, false): + _ = try await dependencies.api.instance.deleteCustomFilter(resource, instance) + serverFilters.removeAll { $0.id == filter.id } + saved = CustomFilter(id: .app(UUID()), label: filter.label, conditions: filter.conditions) + } + + if let saved { + upsert(saved) + } + + persist() + } + + return success ? saved : nil + } + + /// Deletes `filter` from the server or the app. + func delete(_ filter: CustomFilter) async -> Bool { + await request("delete") { + if filter.isOnServer { + _ = try await dependencies.api.instance.deleteCustomFilter(CustomFilterResource(filter, type: type), instance) + serverFilters.removeAll { $0.id == filter.id } + } else { + appFilters.removeAll { $0.id == filter.id } + } + + persist() + } + } + + private var isVoid: Bool { + instance.id == Instance.radarrVoid.id || instance.id == Instance.sonarrVoid.id + } + + private var storeKey: String { + "customFilters:\(instance.id.uuidString):\(type)" + } + + private func request(_ operation: String, reportingErrors: Bool = true, _ perform: () async throws -> Void) async -> Bool { + if reportingErrors { + error = nil + } + + isWorking = true + + var failure: API.Error? + + do { + try await perform() + } catch is CancellationError { + // do nothing + } catch { + let apiError = error as? API.Error ?? API.Error(from: error) + failure = apiError + + leaveBreadcrumb(.error, category: "custom.filters", message: "Request failed", data: ["operation": operation, "error": apiError]) + } + + if reportingErrors { + error = failure + } + + isWorking = false + + return failure == nil + } + + private func upsert(_ filter: CustomFilter) { + if filter.isOnServer { + Self.upsert(filter, into: &serverFilters) + serverFilters = Self.sorted(serverFilters) + } else { + Self.upsert(filter, into: &appFilters) + appFilters = Self.sorted(appFilters) + } + } + + private static func upsert(_ filter: CustomFilter, into filters: inout [CustomFilter]) { + if let index = filters.firstIndex(where: { $0.id == filter.id }) { + filters[index] = filter + } else { + filters.append(filter) + } + } + + private func persist() { + guard !isVoid else { return } + + do { + let data = try JSONEncoder().encode(Cache(app: appFilters, server: serverFilters)) + dependencies.store.set(data, forKey: storeKey) + } catch { + leaveBreadcrumb(.error, category: "custom.filters", message: "Cache encode failed", data: ["error": error]) + } + } + + private static func sorted(_ filters: [CustomFilter]) -> [CustomFilter] { + filters.sorted { $0.label.localizedStandardCompare($1.label) == .orderedAscending } + } + + private struct Cache: Codable { + var app: [CustomFilter] + var server: [CustomFilter] + } +} diff --git a/Ruddarr/Models/CustomFilters/Movie+CustomFilter.swift b/Ruddarr/Models/CustomFilters/Movie+CustomFilter.swift new file mode 100644 index 00000000..d2abbc3e --- /dev/null +++ b/Ruddarr/Models/CustomFilters/Movie+CustomFilter.swift @@ -0,0 +1,91 @@ +import Foundation + +// Radarr's movie index filters (`movieIndexActions.js` `filterBuilderProps`, `movieActions.js` `filterPredicates`). +// Not offered, because `Movie` doesn't decode their data: `originalTitle`, `releaseGroups`, `movieFileQualities`, +// `collection`, `releaseDate`, `keywords` and `qualityCutoffNotMet`. +extension Movie: CustomFilterable { + static let customFilterType = "movieIndex" + + static var customFilterHiddenKeys: Set { ["hasFile"] } + + static let customFilterFields = makeCustomFilterFields() + + private static func makeCustomFilterFields() -> [CustomFilterField] { + let availability: [MovieStatus] = [.announced, .inCinemas, .released] + let statuses: [MovieStatus] = [.tba, .announced, .inCinemas, .released, .deleted] + + return [ + .init(key: "monitored", label: String(localized: "Monitored", comment: "Custom filter field"), builder: .exact, kind: .bool, group: .status), + .init(key: "isAvailable", label: String(localized: "Considered Available", comment: "Custom filter field"), builder: .exact, kind: .bool, group: .status), + .init( + key: "minimumAvailability", label: String(localized: "Minimum Availability", comment: "Custom filter field"), + builder: .exact, kind: .options(availability.map { .init(value: .string($0.rawValue), label: $0.label) }), group: .status + ), + .init(key: "title", label: String(localized: "Title", comment: "Custom filter field"), builder: .string, kind: .text, group: .metadata), + .init(key: "originalLanguage", label: String(localized: "Original Language", comment: "Custom filter field"), builder: .exact, kind: .libraryValues, group: .metadata), + .init( + key: "status", label: String(localized: "Release Status", comment: "Custom filter field"), + builder: .exact, kind: .options(statuses.map { .init(value: .string($0.rawValue), label: $0.label) }), group: .status + ), + .init(key: "studio", label: String(localized: "Studio", comment: "Custom filter field"), builder: .exact, kind: .libraryValues, group: .metadata), + .init(key: "qualityProfileId", label: String(localized: "Quality Profile", comment: "Custom filter field"), builder: .exact, kind: .qualityProfile, group: .library), + .init(key: "added", label: String(localized: "Added", comment: "Custom filter field"), builder: .date, kind: .date, group: .dates), + .init(key: "year", label: String(localized: "Year", comment: "Custom filter field"), builder: .number, kind: .number, group: .metadata), + .init(key: "inCinemas", label: String(localized: "In Cinemas", comment: "Custom filter field"), builder: .date, kind: .date, group: .dates), + .init(key: "physicalRelease", label: String(localized: "Physical Release", comment: "Custom filter field"), builder: .date, kind: .date, group: .dates), + .init(key: "digitalRelease", label: String(localized: "Digital Release", comment: "Custom filter field"), builder: .date, kind: .date, group: .dates), + .init(key: "runtime", label: String(localized: "Runtime", comment: "Custom filter field"), builder: .number, kind: .number, group: .metadata), + .init(key: "path", label: String(localized: "Path", comment: "Custom filter field"), builder: .string, kind: .text, group: .library), + .init(key: "sizeOnDisk", label: String(localized: "Size on Disk", comment: "Custom filter field"), builder: .number, kind: .bytes, group: .library), + .init(key: "genres", label: String(localized: "Genres", comment: "Custom filter field"), builder: .array, kind: .libraryValues, group: .metadata), + .init(key: "tmdbRating", label: String(localized: "TMDb Rating", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init(key: "tmdbVotes", label: String(localized: "TMDb Votes", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init(key: "imdbRating", label: String(localized: "IMDb Rating", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init(key: "imdbVotes", label: String(localized: "IMDb Votes", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init( + key: "rottenTomatoesRating", label: String(localized: "Rotten Tomatoes Rating", comment: "Custom filter field"), + builder: .number, kind: .number, group: .ratings + ), + .init(key: "traktRating", label: String(localized: "Trakt Rating", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init(key: "traktVotes", label: String(localized: "Trakt Votes", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init(key: "popularity", label: String(localized: "Popularity", comment: "Custom filter field"), builder: .number, kind: .number, group: .metadata), + .init(key: "certification", label: String(localized: "Certification", comment: "Custom filter field"), builder: .exact, kind: .libraryValues, group: .metadata), + .init(key: "tags", label: String(localized: "Tags", comment: "Custom filter field"), builder: .array, kind: .tag, group: .library), + ] + } + + // swiftlint:disable:next cyclomatic_complexity + func customFilterValue(for key: String) -> CustomFilterItemValue { + switch key { + case "monitored": .bool(monitored) + case "isAvailable": .bool(isAvailable) + case "minimumAvailability": .string(minimumAvailability.rawValue) + case "title": .string(title) + case "originalLanguage": .string(originalLanguage?.name ?? "") + case "status": .string(status.rawValue) + case "studio": studio.map { .string($0) } ?? .missing + case "qualityProfileId": .number(Double(qualityProfileId)) + case "added": .date(added) + case "year": .number(Double(year)) + case "inCinemas": .date(inCinemas) + case "physicalRelease": .date(physicalRelease) + case "digitalRelease": .date(digitalRelease) + case "runtime": .number(Double(runtime)) + case "path": path.map { .string($0) } ?? .missing + case "sizeOnDisk": .number(Double(sizeOnDisk ?? 0)) + case "genres": .strings(genres) + case "tmdbRating": .decimal(ratings?.tmdb?.value ?? 0, scale: 10) + case "tmdbVotes": .number(Double(ratings?.tmdb?.votes ?? 0)) + case "imdbRating": .decimal(ratings?.imdb?.value ?? 0) + case "imdbVotes": .number(Double(ratings?.imdb?.votes ?? 0)) + case "rottenTomatoesRating": .decimal(ratings?.rottenTomatoes?.value ?? 0) + case "traktRating": .decimal(ratings?.trakt?.value ?? 0, scale: 10) + case "traktVotes": .number(Double(ratings?.trakt?.votes ?? 0)) + case "popularity": popularity.map { .decimal($0) } ?? .missing + case "certification": certification.map { .string($0) } ?? .missing + case "tags": .numbers(tags.map(Double.init)) + case "hasFile": hasFile.map { .bool($0) } ?? .missing + default: .unsupported + } + } +} diff --git a/Ruddarr/Models/CustomFilters/Series+CustomFilter.swift b/Ruddarr/Models/CustomFilters/Series+CustomFilter.swift new file mode 100644 index 00000000..26d4acd1 --- /dev/null +++ b/Ruddarr/Models/CustomFilters/Series+CustomFilter.swift @@ -0,0 +1,118 @@ +import Foundation + +// Sonarr v4's series index filters (`seriesActions.js` `filterBuilderProps` and `filterPredicates`). +// Not offered, because `Series` doesn't decode their data: `releaseGroups`, and the v5 keys +// `averageSizePerEpisode`, `releaseTypes`, `episodeFileQualities` and `episodesMonitoredStatus`. +extension Series: CustomFilterable { + static let customFilterType = "series" + + static var customFilterHiddenKeys: Set { ["missing"] } + + static let customFilterFields = makeCustomFilterFields() + + private static func makeCustomFilterFields() -> [CustomFilterField] { + let statuses: [SeriesStatus] = [.continuing, .upcoming, .ended, .deleted] + let types: [SeriesType] = [.anime, .daily, .standard] + + let monitoredStatuses: [CustomFilterOption] = [ + .init(value: .string("all"), label: String(localized: "All", comment: "Custom filter value: all seasons are monitored")), + .init(value: .string("partial"), label: String(localized: "Partial", comment: "Custom filter value: some seasons are monitored")), + .init(value: .string("none"), label: String(localized: "None", comment: "Custom filter value: no seasons are monitored")), + ] + + return [ + .init(key: "monitored", label: String(localized: "Monitored", comment: "Custom filter field"), builder: .exact, kind: .bool, group: .status), + .init( + key: "status", label: String(localized: "Status", comment: "Custom filter field"), + builder: .exact, kind: .options(statuses.map { .init(value: .string($0.rawValue), label: $0.label) }), group: .status + ), + .init( + key: "seriesType", label: String(localized: "Type", comment: "Custom filter field"), + builder: .exact, kind: .options(types.map { .init(value: .string($0.rawValue), label: $0.label) }), group: .status + ), + .init(key: "title", label: String(localized: "Title", comment: "Custom filter field"), builder: .string, kind: .text, group: .metadata), + .init(key: "network", label: String(localized: "Network", comment: "Custom filter field"), builder: .array, kind: .libraryValues, group: .metadata), + .init(key: "qualityProfileId", label: String(localized: "Quality Profile", comment: "Custom filter field"), builder: .exact, kind: .qualityProfile, group: .library), + .init(key: "nextAiring", label: String(localized: "Next Airing", comment: "Custom filter field"), builder: .date, kind: .date, group: .dates), + .init(key: "previousAiring", label: String(localized: "Previous Airing", comment: "Custom filter field"), builder: .date, kind: .date, group: .dates), + .init(key: "added", label: String(localized: "Added", comment: "Custom filter field"), builder: .date, kind: .date, group: .dates), + .init(key: "seasonCount", label: String(localized: "Season Count", comment: "Custom filter field"), builder: .number, kind: .number, group: .metadata), + .init(key: "episodeProgress", label: String(localized: "Episode Progress", comment: "Custom filter field"), builder: .number, kind: .number, group: .metadata), + .init(key: "path", label: String(localized: "Path", comment: "Custom filter field"), builder: .string, kind: .text, group: .library), + .init(key: "rootFolderPath", label: String(localized: "Root Folder Path", comment: "Custom filter field"), builder: .exact, kind: .libraryValues, group: .library), + .init(key: "sizeOnDisk", label: String(localized: "Size on Disk", comment: "Custom filter field"), builder: .number, kind: .bytes, group: .library), + .init(key: "genres", label: String(localized: "Genres", comment: "Custom filter field"), builder: .array, kind: .libraryValues, group: .metadata), + .init(key: "originalLanguage", label: String(localized: "Original Language", comment: "Custom filter field"), builder: .exact, kind: .libraryValues, group: .metadata), + .init(key: "ratings", label: String(localized: "Rating", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init(key: "ratingVotes", label: String(localized: "Rating Votes", comment: "Custom filter field"), builder: .number, kind: .number, group: .ratings), + .init(key: "certification", label: String(localized: "Certification", comment: "Custom filter field"), builder: .exact, kind: .libraryValues, group: .metadata), + .init(key: "tags", label: String(localized: "Tags", comment: "Custom filter field"), builder: .array, kind: .tag, group: .library), + .init(key: "useSceneNumbering", label: String(localized: "Scene Numbering", comment: "Custom filter field"), builder: .exact, kind: .bool, group: .status), + .init(key: "hasMissingSeason", label: String(localized: "Has Missing Season", comment: "Custom filter field"), builder: .exact, kind: .bool, group: .status), + .init( + key: "seasonsMonitoredStatus", label: String(localized: "Seasons Monitored Status", comment: "Custom filter field"), + builder: .exact, kind: .options(monitoredStatuses), group: .status + ), + .init(key: "year", label: String(localized: "Year", comment: "Custom filter field"), builder: .number, kind: .number, group: .metadata), + ] + } + + // swiftlint:disable:next cyclomatic_complexity + func customFilterValue(for key: String) -> CustomFilterItemValue { + switch key { + case "monitored": .bool(monitored) + case "status": .string(status.rawValue) + case "seriesType": .string(seriesType.rawValue) + case "title": .string(title) + case "network": network.map { .string($0) } ?? .missing + case "qualityProfileId": qualityProfileId.map { .number(Double($0)) } ?? .missing + case "nextAiring": .date(nextAiring) + case "previousAiring": .date(previousAiring) + case "added": .date(added) + case "seasonCount": .number(Double(statistics?.seasonCount ?? 0)) + case "episodeProgress": .number(episodeProgress) + case "path": path.map { .string($0) } ?? .missing + case "rootFolderPath": rootFolderPath.map { .string($0) } ?? .missing + case "sizeOnDisk": .number(Double(statistics?.sizeOnDisk ?? 0)) + case "genres": .strings(genres) + case "originalLanguage": .string(originalLanguage?.name ?? "") + case "ratings": .decimal(ratings?.value ?? 0, scale: 10) + case "ratingVotes": .number(Double(ratings?.votes ?? 0)) + case "certification": certification.map { .string($0) } ?? .missing + case "tags": .numbers(tags.map(Double.init)) + case "useSceneNumbering": .bool(useSceneNumbering) + case "hasMissingSeason": .bool(hasMissingSeason) + case "seasonsMonitoredStatus": .string(seasonsMonitoredStatus) + case "year": .number(Double(year)) + case "missing": .result(statistics.map { $0.episodeCount - $0.episodeFileCount > 0 } ?? false) + default: .unsupported + } + } + + /// Downloaded episodes as a percentage of aired, monitored ones; 100 when there are none. + private var episodeProgress: Double { + guard let statistics, statistics.episodeCount != 0 else { return 100 } + return Double(statistics.episodeFileCount) / Double(statistics.episodeCount) * 100 + } + + /// Whether a regular season has aired in full without a single downloaded episode. + private var hasMissingSeason: Bool { + seasons.contains { season in + let episodeFileCount = season.statistics?.episodeFileCount ?? 0 + let episodeCount = season.statistics?.episodeCount ?? 0 + let totalEpisodeCount = season.statistics?.totalEpisodeCount ?? 0 + + return season.seasonNumber > 0 && totalEpisodeCount > 0 && episodeCount == totalEpisodeCount && episodeFileCount == 0 + } + } + + /// `all`, `partial` or `none`, counting regular seasons only. + private var seasonsMonitoredStatus: String { + let regular = seasons.filter { $0.seasonNumber > 0 } + let monitoredCount = regular.count(where: \.monitored) + + if monitoredCount == 0 { return "none" } + if monitoredCount == regular.count { return "all" } + return "partial" + } +} diff --git a/Ruddarr/Models/Instances/RadarrInstance.swift b/Ruddarr/Models/Instances/RadarrInstance.swift index e1472c01..82227457 100644 --- a/Ruddarr/Models/Instances/RadarrInstance.swift +++ b/Ruddarr/Models/Instances/RadarrInstance.swift @@ -12,6 +12,7 @@ class RadarrInstance { var lookup: MovieLookup var releases: MovieReleases var metadata: MovieMetadata + var customFilters: CustomFilters init(_ instance: Instance = .radarrVoid) { if instance.type != .radarr { @@ -25,6 +26,7 @@ class RadarrInstance { self.lookup = MovieLookup(instance) self.releases = MovieReleases(instance) self.metadata = MovieMetadata(instance) + self.customFilters = CustomFilters(instance, type: Movie.customFilterType) } func switchTo(_ target: Instance) { @@ -35,6 +37,7 @@ class RadarrInstance { self.lookup = MovieLookup(target) self.releases = MovieReleases(target) self.metadata = MovieMetadata(target) + self.customFilters = CustomFilters(target, type: Movie.customFilterType) } var id: UUID { @@ -65,6 +68,7 @@ class RadarrInstance { async let rootFolders = dependencies.api.instance.rootFolders(instance) async let qualityProfiles = dependencies.api.instance.qualityProfiles(instance) async let tags = dependencies.api.instance.tags(instance) + async let customFiltersFetch = customFilters.fetch() var updated = false @@ -95,6 +99,8 @@ class RadarrInstance { leaveBreadcrumb(.error, category: "instance.metadata", message: "Tags fetch failed", data: ["error": error]) } + _ = await customFiltersFetch + guard updated else { return nil } if !movies.items.isEmpty { diff --git a/Ruddarr/Models/Instances/SonarrInstance.swift b/Ruddarr/Models/Instances/SonarrInstance.swift index 279282d9..55fc3394 100644 --- a/Ruddarr/Models/Instances/SonarrInstance.swift +++ b/Ruddarr/Models/Instances/SonarrInstance.swift @@ -13,6 +13,7 @@ class SonarrInstance { var releases: SeriesReleases var episodes: SeriesEpisodes var files: SeriesFiles + var customFilters: CustomFilters init(_ instance: Instance = .sonarrVoid) { if instance.type != .sonarr { @@ -27,6 +28,7 @@ class SonarrInstance { self.releases = SeriesReleases(instance) self.episodes = SeriesEpisodes(instance) self.files = SeriesFiles(instance) + self.customFilters = CustomFilters(instance, type: Series.customFilterType) } func switchTo(_ target: Instance) { @@ -38,6 +40,7 @@ class SonarrInstance { self.releases = SeriesReleases(target) self.episodes = SeriesEpisodes(instance) self.files = SeriesFiles(instance) + self.customFilters = CustomFilters(target, type: Series.customFilterType) } var id: UUID { @@ -68,6 +71,7 @@ class SonarrInstance { async let rootFolders = dependencies.api.instance.rootFolders(instance) async let qualityProfiles = dependencies.api.instance.qualityProfiles(instance) async let tags = dependencies.api.instance.tags(instance) + async let customFiltersFetch = customFilters.fetch() var updated = false @@ -98,6 +102,8 @@ class SonarrInstance { leaveBreadcrumb(.error, category: "instance.metadata", message: "Tags fetch failed", data: ["error": error]) } + _ = await customFiltersFetch + guard updated else { return nil } if !series.items.isEmpty { diff --git a/Ruddarr/Models/Movies/MovieSort.swift b/Ruddarr/Models/Movies/MovieSort.swift index f34a96fb..94185ce3 100644 --- a/Ruddarr/Models/Movies/MovieSort.swift +++ b/Ruddarr/Models/Movies/MovieSort.swift @@ -6,12 +6,16 @@ struct MovieSort: Hashable { var option: Option = .byAdded var filter: Filter = .all var folder: String = .all + var tag: Int? + var customFilter: CustomFilter.ID? static func == (lhs: MovieSort, rhs: MovieSort) -> Bool { lhs.isAscending == rhs.isAscending && lhs.option == rhs.option && lhs.filter == rhs.filter && - lhs.folder == rhs.folder + lhs.folder == rhs.folder && + lhs.tag == rhs.tag && + lhs.customFilter == rhs.customFilter } func filter(_ movie: Movie) -> Bool { @@ -19,6 +23,10 @@ struct MovieSort: Hashable { return false } + if let tag, !movie.tags.contains(tag) { + return false + } + return switch filter { case .all: true @@ -122,12 +130,32 @@ extension MovieSort: RawRepresentable { } } +extension MovieSort { + var isFiltered: Bool { + activeFilterCount > 0 + } + + /// How many of the filters are active, for VoiceOver. + var activeFilterCount: Int { + [filter != .all, folder != .all, tag != nil, customFilter != nil].count(where: \.self) + } + + mutating func clearFilters() { + filter = .all + folder = .all + tag = nil + customFilter = nil + } +} + extension MovieSort: Codable { enum CodingKeys: String, CodingKey { case isAscending case option case filter case folder + case tag + case customFilter } init(from decoder: any Decoder) throws { @@ -137,7 +165,9 @@ extension MovieSort: Codable { isAscending: container.decode(Bool.self, forKey: .isAscending), option: container.decode(Option.self, forKey: .option), filter: container.decode(Filter.self, forKey: .filter), - folder: container.decodeIfPresent(String.self, forKey: .folder) ?? .all + folder: container.decodeIfPresent(String.self, forKey: .folder) ?? .all, + tag: try? container.decodeIfPresent(Int.self, forKey: .tag), + customFilter: try? container.decodeIfPresent(CustomFilter.ID.self, forKey: .customFilter) ) } @@ -147,5 +177,7 @@ extension MovieSort: Codable { try container.encode(option, forKey: .option) try container.encode(filter, forKey: .filter) try container.encode(folder, forKey: .folder) + try container.encodeIfPresent(tag, forKey: .tag) + try container.encodeIfPresent(customFilter, forKey: .customFilter) } } diff --git a/Ruddarr/Models/Movies/Movies.swift b/Ruddarr/Models/Movies/Movies.swift index baac9bd0..c8fc4ad8 100644 --- a/Ruddarr/Models/Movies/Movies.swift +++ b/Ruddarr/Models/Movies/Movies.swift @@ -34,7 +34,7 @@ class Movies { self.instance = instance } - func updateCachedItems(_ sort: MovieSort, _ searchQuery: String) { + func updateCachedItems(_ sort: MovieSort, _ searchQuery: String, customFilter: CustomFilter? = nil) { sortAndFilterTask?.cancel() isFiltering = true @@ -42,7 +42,7 @@ class Movies { let items = self.items let alternateTitles = self.alternateTitles - let result = await Self.filterAndSortItems(items, alternateTitles, sort, searchQuery) + let result = await Self.filterAndSortItems(items, alternateTitles, sort, customFilter, searchQuery) guard !Task.isCancelled else { return } @@ -171,12 +171,16 @@ class Movies { _ items: [Movie], _ alternateTitles: [Movie.ID: String], _ sort: MovieSort, + _ customFilter: CustomFilter?, _ searchQuery: String ) async -> [Movie] { let query = searchQuery.trimmed() + let now = Date.now + let gregorian = Calendar.gregorian(in: .current) let filtered = items .filter(sort.filter) + .filter { customFilter?.matches($0, now: now, calendar: gregorian) ?? true } .filter { guard !query.isEmpty else { return true } return $0.title.localizedCaseInsensitiveContains(query) diff --git a/Ruddarr/Models/Series/SeriesModel.swift b/Ruddarr/Models/Series/SeriesModel.swift index 849ee6ac..53ee61ec 100644 --- a/Ruddarr/Models/Series/SeriesModel.swift +++ b/Ruddarr/Models/Series/SeriesModel.swift @@ -35,7 +35,7 @@ class SeriesModel { self.instance = instance } - func updateCachedItems(_ sort: SeriesSort, _ searchQuery: String) { + func updateCachedItems(_ sort: SeriesSort, _ searchQuery: String, customFilter: CustomFilter? = nil) { sortAndFilterTask?.cancel() isFiltering = true @@ -43,7 +43,7 @@ class SeriesModel { let items = self.items let alternateTitles = self.alternateTitles - let result = await Self.filterAndSortItems(items, alternateTitles, sort, searchQuery) + let result = await Self.filterAndSortItems(items, alternateTitles, sort, customFilter, searchQuery) guard !Task.isCancelled else { return } @@ -189,12 +189,16 @@ class SeriesModel { _ items: [Series], _ alternateTitles: [Series.ID: String], _ sort: SeriesSort, + _ customFilter: CustomFilter?, _ searchQuery: String ) async -> [Series] { let query = searchQuery.trimmed() + let now = Date.now + let gregorian = Calendar.gregorian(in: .current) let filtered = items .filter(sort.filter) + .filter { customFilter?.matches($0, now: now, calendar: gregorian) ?? true } .filter { guard !query.isEmpty else { return true } return $0.title.localizedCaseInsensitiveContains(query) diff --git a/Ruddarr/Models/Series/SeriesSort.swift b/Ruddarr/Models/Series/SeriesSort.swift index 353ff675..989c4af6 100644 --- a/Ruddarr/Models/Series/SeriesSort.swift +++ b/Ruddarr/Models/Series/SeriesSort.swift @@ -6,12 +6,16 @@ struct SeriesSort: Hashable { var option: Option = .byAdded var filter: Filter = .all var folder: String = .all + var tag: Int? + var customFilter: CustomFilter.ID? static func == (lhs: SeriesSort, rhs: SeriesSort) -> Bool { lhs.isAscending == rhs.isAscending && lhs.option == rhs.option && lhs.filter == rhs.filter && - lhs.folder == rhs.folder + lhs.folder == rhs.folder && + lhs.tag == rhs.tag && + lhs.customFilter == rhs.customFilter } func filter(_ series: Series) -> Bool { @@ -19,6 +23,10 @@ struct SeriesSort: Hashable { return false } + if let tag, !series.tags.contains(tag) { + return false + } + return switch filter { case .all: true @@ -124,12 +132,32 @@ extension SeriesSort: RawRepresentable { } } +extension SeriesSort { + var isFiltered: Bool { + activeFilterCount > 0 + } + + /// How many of the filters are active, for VoiceOver. + var activeFilterCount: Int { + [filter != .all, folder != .all, tag != nil, customFilter != nil].count(where: \.self) + } + + mutating func clearFilters() { + filter = .all + folder = .all + tag = nil + customFilter = nil + } +} + extension SeriesSort: Codable { enum CodingKeys: String, CodingKey { case isAscending case option case filter case folder + case tag + case customFilter } init(from decoder: any Decoder) throws { @@ -139,7 +167,9 @@ extension SeriesSort: Codable { isAscending: container.decode(Bool.self, forKey: .isAscending), option: container.decode(Option.self, forKey: .option), filter: container.decode(Filter.self, forKey: .filter), - folder: container.decodeIfPresent(String.self, forKey: .folder) ?? .all + folder: container.decodeIfPresent(String.self, forKey: .folder) ?? .all, + tag: try? container.decodeIfPresent(Int.self, forKey: .tag), + customFilter: try? container.decodeIfPresent(CustomFilter.ID.self, forKey: .customFilter) ) } @@ -149,5 +179,7 @@ extension SeriesSort: Codable { try container.encode(option, forKey: .option) try container.encode(filter, forKey: .filter) try container.encode(folder, forKey: .folder) + try container.encodeIfPresent(tag, forKey: .tag) + try container.encodeIfPresent(customFilter, forKey: .customFilter) } } diff --git a/Ruddarr/Preview Content/custom-filters.json b/Ruddarr/Preview Content/custom-filters.json new file mode 100644 index 00000000..6226ae74 --- /dev/null +++ b/Ruddarr/Preview Content/custom-filters.json @@ -0,0 +1,60 @@ +[ + { + "id": 1, + "type": "movieIndex", + "label": "Recent 4K picks", + "filters": [ + { "key": "monitored", "value": [true], "type": "equal" }, + { "key": "qualityProfileId", "value": [5], "type": "equal" }, + { "key": "added", "value": { "time": "days", "value": 30 }, "type": "inLast" }, + { "key": "tags", "value": [1, 42], "type": "contains" }, + { "key": "imdbRating", "value": [7.5], "type": "greaterThanOrEqual" } + ] + }, + { + "id": 2, + "type": "movieIndex", + "label": "Big non-horror", + "filters": [ + { "key": "genres", "value": ["Horror", "Comedy"], "type": "notContains" }, + { "key": "sizeOnDisk", "value": [21474836480], "type": "greaterThan" }, + { "key": "status", "value": ["released"], "type": "equal" } + ] + }, + { + "id": 3, + "type": "movieIndex", + "label": "Heists", + "filters": [ + { "key": "keywords", "value": ["heist"], "type": "contains" } + ] + }, + { + "id": 4, + "type": "series", + "label": "Anime gaps", + "filters": [ + { "key": "status", "value": ["continuing", "upcoming"], "type": "equal" }, + { "key": "seriesType", "value": ["anime"], "type": "equal" }, + { "key": "hasMissingSeason", "value": [true], "type": "equal" }, + { "key": "seasonsMonitoredStatus", "value": ["all"], "type": "equal" } + ] + }, + { + "id": 5, + "type": "series", + "label": "Not trash, HD", + "filters": [ + { "key": "tags", "value": [2], "type": "notContains" }, + { "key": "qualityProfileId", "value": [3, 4, 6], "type": "equal" } + ] + }, + { + "id": 6, + "type": "history", + "label": "Failed downloads", + "filters": [ + { "key": "eventType", "value": 4, "type": "equal" } + ] + } +] diff --git a/Ruddarr/Views/Movies/MoviesView+Overlay.swift b/Ruddarr/Views/Movies/MoviesView+Overlay.swift index cd65b155..380ef206 100644 --- a/Ruddarr/Views/Movies/MoviesView+Overlay.swift +++ b/Ruddarr/Views/Movies/MoviesView+Overlay.swift @@ -13,10 +13,9 @@ struct NoMovieSearchResults: View { } description: { Text(description.toMarkdown()) } actions: { - if sort.filter != .all || sort.folder != .all { + if sort.isFiltered { Button("Clear Filters") { - sort.filter = .all - sort.folder = .all + sort.clearFilters() } } } @@ -47,10 +46,9 @@ struct MovieSearchSuggestion: View { return .handled }) - if sort.filter != .all || sort.folder != .all { + if sort.isFiltered { Button("Clear Filters") { - sort.filter = .all - sort.folder = .all + sort.clearFilters() } .font(.subheadline) .padding(.top, 8) @@ -67,10 +65,9 @@ struct NoMatchingMovies: View { } description: { Text("No movies match the selected filters.") } actions: { - if sort.filter != .all || sort.folder != .all { + if sort.isFiltered { Button("Clear Filters") { - sort.filter = .all - sort.folder = .all + sort.clearFilters() } } } diff --git a/Ruddarr/Views/Movies/MoviesView+Toolbar.swift b/Ruddarr/Views/Movies/MoviesView+Toolbar.swift index db22fa4b..f3052066 100644 --- a/Ruddarr/Views/Movies/MoviesView+Toolbar.swift +++ b/Ruddarr/Views/Movies/MoviesView+Toolbar.swift @@ -59,10 +59,17 @@ extension MoviesView { Label("Root Folder", systemImage: "folder") } }.pickerStyle(.menu) + + TagFilterPicker(tags: instance.tags, selection: $sort.tag) + + CustomFiltersMenuSection(filters: instance.customFilters, serverName: "Radarr", selection: $sort.customFilter) { + showCustomFilters = true + } } label: { ToolbarFilterLabel( - active: sort.filter != .all || sort.folder != .all, - tint: settings.theme.tint + active: sort.isFiltered, + tint: settings.theme.tint, + count: sort.activeFilterCount ) } } @@ -163,4 +170,18 @@ extension MoviesView { } } } + + var manageFiltersSheet: some View { + CustomFiltersSheet( + context: CustomFilterContext( + filters: instance.customFilters, + serverName: "Radarr", + tags: instance.tags, + qualityProfiles: instance.qualityProfiles, + items: instance.movies.items + ), + selection: $sort.customFilter + ) + .environment(settings) + } } diff --git a/Ruddarr/Views/MoviesView.swift b/Ruddarr/Views/MoviesView.swift index 7ddbeeab..6bfff335 100644 --- a/Ruddarr/Views/MoviesView.swift +++ b/Ruddarr/Views/MoviesView.swift @@ -29,6 +29,7 @@ struct MoviesView: View { @State private var lastFetch: Date = .distantPast @State private var movieToDelete: Movie? + @State var showCustomFilters = false var body: some View { // swiftlint:disable:next closure_body_length @@ -39,7 +40,7 @@ struct MoviesView: View { } else { ScrollViewReader { proxy in ScrollView { - mediaGrid + gridWithFilterChips if instance.movies.cachedItems.count > 42 { mediaCount @@ -102,6 +103,7 @@ struct MoviesView: View { .onChange(of: sort, handleFilterChange) .onChange(of: searchQuery, handleQueryChange) .onChange(of: instance.movies.items, updateDisplayedMovies) + .customFiltersSheet(isPresented: $showCustomFilters, selection: $sort.customFilter, in: instance.customFilters, onChange: updateDisplayedMovies) { manageFiltersSheet } .task(id: searchRequest) { guard let searchRequest, await searchRequest.waitForDebounce() else { return } updateDisplayedMovies() @@ -221,7 +223,7 @@ struct MoviesView: View { } func updateDisplayedMovies() { - instance.movies.updateCachedItems(sort, searchQuery) + instance.movies.updateCachedItems(sort, searchQuery, customFilter: instance.customFilters.byId(sort.customFilter)) } func fetchMoviesWithMetadata() { diff --git a/Ruddarr/Views/Series/SeriesView+Overlay.swift b/Ruddarr/Views/Series/SeriesView+Overlay.swift index 0063f6cd..85d4e63a 100644 --- a/Ruddarr/Views/Series/SeriesView+Overlay.swift +++ b/Ruddarr/Views/Series/SeriesView+Overlay.swift @@ -13,10 +13,9 @@ struct NoSeriesSearchResults: View { } description: { Text(description.toMarkdown()) } actions: { - if sort.filter != .all || sort.folder != .all { + if sort.isFiltered { Button("Clear Filters") { - sort.filter = .all - sort.folder = .all + sort.clearFilters() } } } @@ -47,10 +46,9 @@ struct SeriesSearchSuggestion: View { return .handled }) - if sort.filter != .all || sort.folder != .all { + if sort.isFiltered { Button("Clear Filters") { - sort.filter = .all - sort.folder = .all + sort.clearFilters() } .font(.subheadline) .padding(.top, 8) @@ -67,10 +65,9 @@ struct NoMatchingSeries: View { } description: { Text("No series match the selected filters.") } actions: { - if sort.filter != .all || sort.folder != .all { + if sort.isFiltered { Button("Clear Filters") { - sort.filter = .all - sort.folder = .all + sort.clearFilters() } } } diff --git a/Ruddarr/Views/Series/SeriesView+Toolbar.swift b/Ruddarr/Views/Series/SeriesView+Toolbar.swift index 77cee50d..6f26d833 100644 --- a/Ruddarr/Views/Series/SeriesView+Toolbar.swift +++ b/Ruddarr/Views/Series/SeriesView+Toolbar.swift @@ -59,10 +59,17 @@ extension SeriesView { Label("Root Folder", systemImage: "folder") } }.pickerStyle(.menu) + + TagFilterPicker(tags: instance.tags, selection: $sort.tag) + + CustomFiltersMenuSection(filters: instance.customFilters, serverName: "Sonarr", selection: $sort.customFilter) { + showCustomFilters = true + } } label: { ToolbarFilterLabel( - active: sort.filter != .all || sort.folder != .all, - tint: settings.theme.tint + active: sort.isFiltered, + tint: settings.theme.tint, + count: sort.activeFilterCount ) } } @@ -163,4 +170,18 @@ extension SeriesView { } } } + + var manageFiltersSheet: some View { + CustomFiltersSheet( + context: CustomFilterContext( + filters: instance.customFilters, + serverName: "Sonarr", + tags: instance.tags, + qualityProfiles: instance.qualityProfiles, + items: instance.series.items + ), + selection: $sort.customFilter + ) + .environment(settings) + } } diff --git a/Ruddarr/Views/SeriesView.swift b/Ruddarr/Views/SeriesView.swift index 05c0525e..1793dfa8 100644 --- a/Ruddarr/Views/SeriesView.swift +++ b/Ruddarr/Views/SeriesView.swift @@ -32,6 +32,7 @@ struct SeriesView: View { @State private var navigationTask: Task? @State private var seriesToDelete: Series? + @State var showCustomFilters = false var body: some View { // swiftlint:disable:next closure_body_length @@ -42,7 +43,7 @@ struct SeriesView: View { } else { ScrollViewReader { proxy in ScrollView { - mediaGrid + gridWithFilterChips if instance.series.cachedItems.count > 42 { mediaCount @@ -105,6 +106,7 @@ struct SeriesView: View { .onChange(of: sort, handleFilterChange) .onChange(of: searchQuery, handleQueryChange) .onChange(of: instance.series.items, updateDisplayedSeries) + .customFiltersSheet(isPresented: $showCustomFilters, selection: $sort.customFilter, in: instance.customFilters, onChange: updateDisplayedSeries) { manageFiltersSheet } .task(id: searchRequest) { guard let searchRequest, await searchRequest.waitForDebounce() else { return } updateDisplayedSeries() @@ -232,7 +234,7 @@ struct SeriesView: View { } func updateDisplayedSeries() { - instance.series.updateCachedItems(sort, searchQuery) + instance.series.updateCachedItems(sort, searchQuery, customFilter: instance.customFilters.byId(sort.customFilter)) } func fetchSeriesWithMetadata() { diff --git a/Ruddarr/Views/Shared/CustomFilters/ActiveFilterChips.swift b/Ruddarr/Views/Shared/CustomFilters/ActiveFilterChips.swift new file mode 100644 index 00000000..c74e4e62 --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/ActiveFilterChips.swift @@ -0,0 +1,145 @@ +import SwiftUI + +/// A horizontally scrolling row of chips for the active filters, shown above the Movies and Series grids. +struct ActiveFilterChips: View { + @ViewBuilder let content: Content + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + content + } + } + .scrollIndicators(.hidden) + .scrollClipDisabled() + // The grid's `ScrollView` can set content margins, which would otherwise inset this row twice. + .contentMargins(.horizontal, 0, for: .scrollContent) + .containerHorizontalMargins() + #if os(macOS) + .padding(.top) + #else + .padding(.top, 4) + .padding(.bottom, 12) + #endif + } +} + +/// One active filter. Tapping it clears that filter. +struct ActiveFilterChip: View { + /// What kind of filter this is, read by VoiceOver before the title, e.g. "Tag". + let kind: LocalizedStringKey + let clear: () -> Void + @ViewBuilder let title: Title + + var body: some View { + Button(action: clear) { + HStack(spacing: 6) { + title + .lineLimit(1) + + Image(systemName: "xmark") + .imageScale(.small) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .font(.subheadline) + } + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + .tint(.primary) + .accessibilityLabel { label in + Text(kind) + label + Text("Remove Filter") + } + } +} + +extension MoviesView { + /// The chips of the active filters, above the grid. + @ViewBuilder + var filterChips: some View { + if sort.isFiltered { + ActiveFilterChips { + if sort.filter != .all { + ActiveFilterChip(kind: "Filter") { sort.filter = .all } title: { sort.filter.label } + } + + if sort.folder != .all { + ActiveFilterChip(kind: "Root Folder") { sort.folder = .all } title: { + Label(instance.rootFolders.chipLabel(for: sort.folder), systemImage: "folder") + } + } + + if let tag = sort.tag { + ActiveFilterChip(kind: "Tag") { sort.tag = nil } title: { + Label(instance.tags.label(for: tag) ?? String(localized: "Tag"), systemImage: "tag") + } + } + + if sort.customFilter != nil { + ActiveFilterChip(kind: "Custom Filter") { sort.customFilter = nil } title: { + Label(instance.customFilters.chipLabel(for: sort.customFilter), systemImage: "line.3.horizontal.decrease") + } + } + } + } + } + + @ViewBuilder + var gridWithFilterChips: some View { + filterChips + mediaGrid + } +} + +extension SeriesView { + /// The chips of the active filters, above the grid. + @ViewBuilder + var filterChips: some View { + if sort.isFiltered { + ActiveFilterChips { + if sort.filter != .all { + ActiveFilterChip(kind: "Filter") { sort.filter = .all } title: { sort.filter.label } + } + + if sort.folder != .all { + ActiveFilterChip(kind: "Root Folder") { sort.folder = .all } title: { + Label(instance.rootFolders.chipLabel(for: sort.folder), systemImage: "folder") + } + } + + if let tag = sort.tag { + ActiveFilterChip(kind: "Tag") { sort.tag = nil } title: { + Label(instance.tags.label(for: tag) ?? String(localized: "Tag"), systemImage: "tag") + } + } + + if sort.customFilter != nil { + ActiveFilterChip(kind: "Custom Filter") { sort.customFilter = nil } title: { + Label(instance.customFilters.chipLabel(for: sort.customFilter), systemImage: "line.3.horizontal.decrease") + } + } + } + } + } + + @ViewBuilder + var gridWithFilterChips: some View { + filterChips + mediaGrid + } +} + +private extension [InstanceRootFolder] { + func chipLabel(for path: String) -> String { + first { $0.path == path }?.menuLabel(among: self) ?? path + } +} + +private extension CustomFilters { + func chipLabel(for id: CustomFilter.ID?) -> String { + byId(id)?.label ?? String(localized: "Custom Filter") + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFilter+Display.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFilter+Display.swift new file mode 100644 index 00000000..10c085fb --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFilter+Display.swift @@ -0,0 +1,221 @@ +import SwiftUI + +/// What the filter views need from the instance a filter belongs to. +struct CustomFilterContext { + let filters: CustomFilters + /// "Radarr" or "Sonarr". + let serverName: String + let tags: [Tag] + let qualityProfiles: [InstanceQualityProfile] + let items: [Item] +} + +extension CustomFilters { + var alertBinding: Binding { + .init(get: { self.error != nil }, set: { _ in }) + } +} + +extension CustomFilter { + /// A new, empty filter saved in the app. + static func new() -> CustomFilter { + CustomFilter(id: .app(UUID()), label: "", conditions: []) + } +} + +extension CustomFilterOperator { + /// A short, sentence-friendly label for this operator, which depends on the builder type for some operators. + func label(for builder: CustomFilterBuilderType) -> String { + switch (self, builder) { + case (.greaterThan, .date): String(localized: "is after", comment: "Custom filter operator") + case (.lessThan, .date): String(localized: "is before", comment: "Custom filter operator") + default: genericLabel + } + } + + private var genericLabel: String { + switch self { + case .contains: String(localized: "contains", comment: "Custom filter operator") + case .notContains: String(localized: "does not contain", comment: "Custom filter operator") + case .equal: String(localized: "is", comment: "Custom filter operator") + case .notEqual: String(localized: "is not", comment: "Custom filter operator") + case .greaterThan: String(localized: "is above", comment: "Custom filter operator") + case .greaterThanOrEqual: String(localized: "is at least", comment: "Custom filter operator") + case .lessThan: String(localized: "is below", comment: "Custom filter operator") + case .lessThanOrEqual: String(localized: "is at most", comment: "Custom filter operator") + case .startsWith: String(localized: "starts with", comment: "Custom filter operator") + case .notStartsWith: String(localized: "does not start with", comment: "Custom filter operator") + case .endsWith: String(localized: "ends with", comment: "Custom filter operator") + case .notEndsWith: String(localized: "does not end with", comment: "Custom filter operator") + case .inLast: String(localized: "in the last", comment: "Custom filter operator") + case .notInLast: String(localized: "not in the last", comment: "Custom filter operator") + case .inNext: String(localized: "in the next", comment: "Custom filter operator") + case .notInNext: String(localized: "not in the next", comment: "Custom filter operator") + } + } + + /// `>`, `>=`, `<` and `<=`, where a second value is meaningless. + var isComparison: Bool { + switch self { + case .greaterThan, .greaterThanOrEqual, .lessThan, .lessThanOrEqual: true + default: false + } + } +} + +extension CustomFilterFieldGroup { + var label: String { + switch self { + case .status: String(localized: "Status", comment: "Custom filter field section") + case .library: String(localized: "Library", comment: "Custom filter field section") + case .dates: String(localized: "Dates", comment: "Custom filter field section") + case .ratings: String(localized: "Ratings", comment: "Custom filter field section") + case .metadata: String(localized: "Metadata", comment: "Custom filter field section") + } + } +} + +extension CustomFilterTimeUnit { + var label: String { + switch self { + case .seconds: String(localized: "Seconds") + case .minutes: String(localized: "Minutes") + case .hours: String(localized: "Hours") + case .days: String(localized: "Days") + case .weeks: String(localized: "Weeks") + case .months: String(localized: "Months") + } + } +} + +extension CustomFilterField { + /// The value the web UI's builder starts with for this field and operator. + func defaultValue(for op: CustomFilterOperator) -> CustomFilterValue { + guard kind == .date else { return .array([]) } + + return op.isRelativeDate + ? .object(["time": .string(CustomFilterTimeUnit.days.rawValue), "value": .null]) + : .string(CustomFilterDate.string(from: .now)) + } +} + +extension CustomFilterValue { + /// Whether the builder has no value yet: an empty array or string, or a relative date without an amount. + var isEmptyValue: Bool { + switch self { + case .null: true + case .array(let values): values.isEmpty + case .string(let value): value.trimmed().isEmpty + case .object(let object): object["value"] == nil || object["value"] == .null + default: false + } + } + + var arrayValues: [CustomFilterValue] { + switch self { + case .array(let values): values + case .null: [] + default: [self] + } + } + + var intValue: Int? { + switch self { + case .int(let value): value + case .double(let value): Int(exactly: value.rounded()) + default: nil + } + } + + var doubleValue: Double? { + switch self { + case .int(let value): Double(value) + case .double(let value): value + default: nil + } + } + + /// Strings as they are, anything else as raw JSON-like text. + var rawText: String { + if case .string(let string) = self { return string } + return displayString + } + + /// The value as raw JSON-like text, for conditions Ruddarr can't edit. + var displayString: String { + switch self { + case .null: "null" + case .bool(let value): value ? "true" : "false" + case .int(let value): String(value) + case .double(let value): value.formatted(.number.grouping(.never)) + case .string(let value): "\"\(value)\"" + case .array(let values): "[" + values.map(\.displayString).joined(separator: ", ") + "]" + case .object(let values): + "{" + values.sorted { $0.key < $1.key } + .map { "\"\($0.key)\": \($0.value.displayString)" } + .joined(separator: ", ") + "}" + } + } +} + +/// Date-only `"yyyy-mm-dd"` strings in the local calendar, as the web UI's date input stores them. +enum CustomFilterDate { + static func string(from date: Date) -> String { + let components = Calendar.gregorian(in: .current).dateComponents([.year, .month, .day], from: date) + + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + + static func date(from string: String) -> Date? { + CustomFilterValue.string(string).localMidnight(in: .gregorian(in: .current)) + } +} + +/// The units a size can be entered in, 1024-based like the web UI. +enum CustomFilterByteUnit: Int, CaseIterable, Identifiable { + case megabytes = 2 + case gigabytes = 3 + case terabytes = 4 + + var id: Self { self } + + var multiplier: Double { + pow(1_024, Double(rawValue)) + } + + var label: String { + switch self { + case .megabytes: "MB" + case .gigabytes: "GB" + case .terabytes: "TB" + } + } + + /// The largest unit `bytes` is at least one of. + static func unit(for bytes: Double) -> Self { + allCases.last { bytes >= $0.multiplier } ?? .megabytes + } +} + +extension [Tag] { + /// The tags sorted by label. + var sortedByLabel: [Tag] { + sorted { $0.label.localizedStandardCompare($1.label) == .orderedAscending } + } + + /// The label of the tag with `id`, if there is one. + func label(for id: Int?) -> String? { + first { $0.id == id }?.label + } +} + +extension View { + /// The title of a custom filters sheet, except on macOS, where it leaks into the main window's toolbar. + func customFilterSheetTitle(_ title: LocalizedStringKey) -> some View { + #if os(macOS) + self + #else + navigationTitle(title) + #endif + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionHeader.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionHeader.swift new file mode 100644 index 00000000..5b97e03c --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionHeader.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// A condition section's header: the condition as a sentence, and a button to remove it. +struct CustomFilterConditionHeader: View { + let summary: String + let onDelete: () -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(summary) + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + + Button(role: .destructive, action: onDelete) { + Label("Remove condition", systemImage: "minus.circle") + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .foregroundStyle(.red) + .tint(.red) + .help("Remove condition") + } + } +} + +/// The footer hint of a condition that has no value yet. +struct CustomFilterConditionEmptyHint: View { + var body: some View { + Label("Add a value to save this filter.", systemImage: "exclamationmark.circle") + .foregroundStyle(.orange) + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSection.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSection.swift new file mode 100644 index 00000000..71a0e065 --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSection.swift @@ -0,0 +1,244 @@ +import SwiftUI + +/// One editable condition of the filter editor: field, operator and value. +struct CustomFilterConditionSection: View { + let context: CustomFilterContext + let field: CustomFilterField + @Binding var condition: CustomFilterCondition + let onDelete: () -> Void + + /// Fields whose ratings are stored on a 0–100 scale. + private static var percentRatingKeys: Set { ["tmdbRating", "traktRating", "ratings"] } + + var body: some View { + Section { + Picker("Field", selection: fieldBinding) { + CustomFilterFieldPickerContent(fields: Item.customFilterFields) { field in + Text(field.label).tag(field.key) + } + } + + Picker("Operator", selection: operatorBinding) { + ForEach(field.builder.operators) { op in + Text(op.label(for: field.builder)).tag(op) + } + } + + valueEditor + .id(field.key) + } header: { + CustomFilterConditionHeader(summary: context.summary(of: condition, field: field), onDelete: onDelete) + } footer: { + if condition.value.isEmptyValue { + CustomFilterConditionEmptyHint() + } + } + } + + @ViewBuilder + var valueEditor: some View { + switch field.kind { + case .text, .number, .bytes: + CustomFilterListValueEditor( + kind: field.kind, + allowsMultiple: !(condition.operator?.isComparison ?? false), + isPercentScale: Self.percentRatingKeys.contains(field.key), + value: $condition.value + ) + case .date: + CustomFilterDateValueEditor(isRelative: condition.operator?.isRelativeDate ?? false, value: $condition.value) + case .bool: + CustomFilterBoolValueEditor(value: $condition.value) + case .options, .tag, .qualityProfile, .libraryValues: + // `.libraryValues` options scan the whole library, so they're computed once per render. + let options = context.options(for: field, including: condition.value) + + NavigationLink { + CustomFilterOptionList(title: field.label, options: options, value: $condition.value) + } label: { + LabeledContent("Values") { + Text(selectionSummary(options)) + .lineLimit(1) + } + } + } + } + + var fieldBinding: Binding { + Binding( + get: { condition.key }, + set: { key in + guard key != condition.key, + let newField = Item.customFilterFields.first(where: { $0.key == key }), + let op = newField.builder.operators.first + else { return } + + condition = CustomFilterCondition(key: key, value: newField.defaultValue(for: op), operator: op) + } + ) + } + + var operatorBinding: Binding { + Binding( + get: { condition.operator ?? field.builder.operators[0] }, + set: { op in + let previous = condition.operator + condition.type = op.rawValue + + if field.kind == .date, previous?.isRelativeDate != op.isRelativeDate { + condition.value = field.defaultValue(for: op) + } + } + ) + } + + func selectionSummary(_ options: [CustomFilterOption]) -> String { + let selected = condition.value.arrayValues + guard !selected.isEmpty else { return String(localized: "None") } + + return options.labels(of: selected).formatted(.list(type: .or)) + } +} + +/// The builder's fields, in sections (Status, Library, …), for a `Picker` or `Menu`. +struct CustomFilterFieldPickerContent: View { + let fields: [CustomFilterField] + @ViewBuilder let row: (CustomFilterField) -> Row + + var body: some View { + ForEach(CustomFilterFieldGroup.allCases) { group in + let fields = fields.filter { $0.group == group } + + if !fields.isEmpty { + Section(group.label) { + ForEach(fields) { field in + row(field) + } + } + } + } + } +} + +/// A single Yes/No choice, stored as `[true]` or `[false]`. +struct CustomFilterBoolValueEditor: View { + @Binding var value: CustomFilterValue + + var body: some View { + Picker("Value", selection: selection) { + // A stored value with both or neither shows as unset until the user picks one. + if selection.wrappedValue == nil { + Text("Choose").tag(Bool?.none) + } + + ForEach(CustomFilterOption.yesNo) { option in + if case .bool(let bool) = option.value { + Text(option.label).tag(Bool?.some(bool)) + } + } + } + } + + var selection: Binding { + Binding( + get: { + let values = value.arrayValues + guard values.count == 1, case .bool(let bool) = values[0] else { return nil } + return bool + }, + set: { bool in + value = bool.map { .array([.bool($0)]) } ?? .array([]) + } + ) + } +} + +/// A searchable list of options with checkmarks, for tags, quality profiles and library values. +struct CustomFilterOptionList: View { + let title: String + let options: [CustomFilterOption] + @Binding var value: CustomFilterValue + + @State private var query = "" + + var body: some View { + Form { + Section { + ForEach(filteredOptions) { option in + CustomFilterCheckmarkRow( + label: option.label, + isSelected: value.arrayValues.contains(option.value) + ) { + value = Self.toggle(option.value, in: value) + } + } + } + } + .formStyle(.grouped) + .navigationTitle(title) + .safeNavigationBarTitleDisplayMode(.inline) + .overlay { + if options.isEmpty { + ContentUnavailableView("No Values", systemImage: "list.bullet") + } + } + .modifier(SearchableIfLong(query: $query, isLong: options.count > 12)) + } + + static func toggle(_ option: CustomFilterValue, in value: CustomFilterValue) -> CustomFilterValue { + var selected = value.arrayValues + + if let index = selected.firstIndex(of: option) { + selected.remove(at: index) + } else { + selected.append(option) + } + + return .array(selected) + } + + var filteredOptions: [CustomFilterOption] { + let query = query.trimmed() + guard !query.isEmpty else { return options } + return options.filter { $0.label.localizedCaseInsensitiveContains(query) } + } +} + +private struct SearchableIfLong: ViewModifier { + @Binding var query: String + let isLong: Bool + + func body(content: Content) -> some View { + if isLong { + content.searchable(text: $query) + } else { + content + } + } +} + +struct CustomFilterCheckmarkRow: View { + let label: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Text(label) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .fontWeight(.semibold) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSummary.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSummary.swift new file mode 100644 index 00000000..0c1639e8 --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFilterConditionSummary.swift @@ -0,0 +1,138 @@ +import SwiftUI + +extension CustomFilterOption { + /// The choices of a `.bool` field. + static var yesNo: [CustomFilterOption] { + [ + CustomFilterOption(value: .bool(true), label: String(localized: "Yes")), + CustomFilterOption(value: .bool(false), label: String(localized: "No")), + ] + } +} + +extension [CustomFilterOption] { + /// The labels of `values`, in their order. A value without an option shows as its raw text. + func labels(of values: [CustomFilterValue]) -> [String] { + values.map { value in first { $0.value == value }?.label ?? value.rawText } + } +} + +extension CustomFilterContext { + /// The options of `field`, plus any values of `value` that aren't among them. + func options(for field: CustomFilterField, including value: CustomFilterValue) -> [CustomFilterOption] { + var options: [CustomFilterOption] = switch field.kind { + case .bool: + CustomFilterOption.yesNo + case .options(let options): + options + case .tag: + tags.sortedByLabel + .map { CustomFilterOption(value: .int($0.id), label: $0.label) } + case .qualityProfile: + qualityProfiles + .map { CustomFilterOption(value: .int($0.id), label: $0.name) } + case .libraryValues: + Item.libraryValues(for: field.key, in: items) + .map { CustomFilterOption(value: .string($0), label: $0) } + default: + [] + } + + for value in value.arrayValues where !options.contains(where: { $0.value == value }) { + options.append(CustomFilterOption(value: value, label: value.rawText)) + } + + return options + } + + /// The condition as a sentence, such as "Quality Profile is Ultra-HD (4K)" or "Added in the last 30 days". + func summary(of condition: CustomFilterCondition, field: CustomFilterField) -> String { + let op = condition.operator ?? field.builder.operators[0] + let value = condition.value.isEmptyValue ? "…" : valueSummary(of: condition.value, field: field, operator: op) + + return "\(field.label) \(op.label(for: field.builder)) \(value)" + } + + private func valueSummary(of value: CustomFilterValue, field: CustomFilterField, operator op: CustomFilterOperator) -> String { + switch field.kind { + case .date: + return Self.dateSummary(of: value, isRelative: op.isRelativeDate) + case .bool, .options, .tag, .qualityProfile: + return options(for: field, including: value) + .labels(of: value.arrayValues) + .formatted(.list(type: .or)) + case .number: + return value.arrayValues + .map { $0.doubleValue?.formatted() ?? $0.rawText } + .formatted(.list(type: .or)) + case .bytes: + return value.arrayValues + .map { $0.doubleValue.map { formatBytes(Float($0)) } ?? $0.rawText } + .formatted(.list(type: .or)) + case .text, .libraryValues: + return value.arrayValues.map(\.rawText).formatted(.list(type: .or)) + } + } + + private static func dateSummary(of value: CustomFilterValue, isRelative: Bool) -> String { + if isRelative { + guard let relative = CustomFilterRelativeDate(value) else { return value.displayString } + return relative.formatted + } + + guard case .string(let string) = value, let date = CustomFilterDate.date(from: string) else { + return value.rawText + } + + return date.formatted(date: .abbreviated, time: .omitted) + } +} + +extension CustomFilterCondition { + /// A raw but readable sentence for a condition Ruddarr can't edit, such as "keywords contains heist". + var rawSummary: String { + let op = self.operator?.label(for: .string) ?? type + let values = value.arrayValues + + let value = values.isEmpty + ? "…" + : values.map { $0.isScalarDisplay ? $0.rawText : $0.displayString }.formatted(.list(type: .or)) + + return "\(key) \(op) \(value)" + } +} + +private extension CustomFilterValue { + var isScalarDisplay: Bool { + switch self { + case .array, .object: false + default: true + } + } +} + +private extension CustomFilterRelativeDate { + /// "30 days", "1 month". + var formatted: String { + let amount = Int(amount.rounded()) + var components = DateComponents() + let allowed: NSCalendar.Unit + + // Only the condition's own unit, so 30 days stays "30 days" rather than "4 weeks, 2 days". + switch unit { + case .seconds: components.second = amount; allowed = .second + case .minutes: components.minute = amount; allowed = .minute + case .hours: components.hour = amount; allowed = .hour + case .days: components.day = amount; allowed = .day + case .weeks: components.weekOfMonth = amount; allowed = .weekOfMonth + case .months: components.month = amount; allowed = .month + } + + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.allowedUnits = allowed + formatter.zeroFormattingBehavior = .default + + return formatter.string(from: components) ?? "\(amount)" + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFilterEditor.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFilterEditor.swift new file mode 100644 index 00000000..90fb3583 --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFilterEditor.swift @@ -0,0 +1,224 @@ +import SwiftUI + +/// Creates or edits a custom filter with the web UI's condition builder. +struct CustomFilterEditor: View { + let context: CustomFilterContext + let original: CustomFilter + let isNew: Bool + let onSave: (CustomFilter) -> Void + + @State private var label: String + @State private var rows: [CustomFilterConditionRow] + @State private var onServer: Bool + @State private var isConfirmingMove = false + + @Environment(AppSettings.self) private var settings + @Environment(\.dismiss) private var dismiss + + init( + context: CustomFilterContext, + filter: CustomFilter, + isNew: Bool, + onSave: @escaping (CustomFilter) -> Void + ) { + self.context = context + self.original = filter + self.isNew = isNew + self.onSave = onSave + + self._label = State(initialValue: filter.label) + self._onServer = State(initialValue: filter.isOnServer) + self._rows = State(initialValue: filter.conditions.map { condition in + CustomFilterConditionRow( + condition: condition, + isEditable: condition.isSupported(for: Item.self) && Self.field(for: condition) != nil + ) + }) + } + + var filters: CustomFilters { context.filters } + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Name", text: $label, prompt: Text("Filter Name")) + .labelsHidden() + } + + ForEach($rows) { $row in + conditionSection($row) + } + + CustomFilterEditorAddSection(items: context.items, filter: filter, onAdd: addCondition) + + CustomFilterEditorStorageSection( + serverName: context.serverName, + onServer: $onServer, + isMovingToDevice: isMovingToDevice + ) + } + .formStyle(.grouped) + .customFilterSheetTitle(isNew ? "New Filter" : "Edit Filter") + .safeNavigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + .horizontalToolbarOnly() + .sensoryAlert(isPresented: filters.alertBinding, error: filters.error) { _ in + Button("OK") { filters.error = nil } + } message: { error in + Text(error.recoverySuggestionFallback) + } + .confirmationDialog( + String(localized: "Remove \"\(original.label)\" from \(context.serverName)?"), + isPresented: $isConfirmingMove, + titleVisibility: .visible + ) { + Button("Remove from \(context.serverName)", role: .destructive) { + Task { await save() } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("It will disappear from the web interface and other devices.") + } + .tint(settings.theme.tint) + } + } + + @ViewBuilder + func conditionSection(_ row: Binding) -> some View { + if row.wrappedValue.isEditable, let field = Self.field(for: row.wrappedValue.condition) { + CustomFilterConditionSection( + context: context, + field: field, + condition: row.condition, + onDelete: { removeRow(row.wrappedValue.id) } + ) + } else { + CustomFilterUnsupportedSection( + condition: row.wrappedValue.condition, + onDelete: { removeRow(row.wrappedValue.id) } + ) + } + } + + var isMovingToDevice: Bool { + original.isOnServer && !isNew && !onServer + } + + /// The filter as currently edited. + var filter: CustomFilter { + CustomFilter(id: original.id, label: label.trimmed(), conditions: rows.map(\.condition)) + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", systemImage: "xmark") { + dismiss() + } + .tint(.primary) + .hideIconOnMac() + } + + #if os(macOS) + ToolbarItem(placement: .confirmationAction) { + saveButton + .buttonStyle(.borderedProminent) + } + #else + ToolbarItem(placement: .primaryAction) { + saveButton + .prominentGlassButtonStyle(canSave) + } + #endif + } + + var saveButton: some View { + Button { + if isMovingToDevice { + isConfirmingMove = true + } else { + Task { await save() } + } + } label: { + if filters.isWorking { + ButtonProgressView() + } else { + Label("Save", systemImage: "checkmark") + .hideIconOnMac() + } + } + .disabled(!canSave) + } + + var canSave: Bool { + !label.trimmed().isEmpty && !rows.isEmpty && !filters.isWorking && !hasEmptyCondition + } + + /// Whether an editable condition has no value yet. Read-only conditions are saved back as they are. + var hasEmptyCondition: Bool { + rows.contains { $0.isEditable && $0.condition.value.isEmptyValue } + } + + static func field(for condition: CustomFilterCondition) -> CustomFilterField? { + guard let field = Item.customFilterFields.first(where: { $0.key == condition.key }), + let op = condition.operator, + field.builder.operators.contains(op) + else { return nil } + + return field + } + + func addCondition(_ field: CustomFilterField) { + guard let op = field.builder.operators.first else { return } + + rows.append(CustomFilterConditionRow( + condition: CustomFilterCondition(key: field.key, value: field.defaultValue(for: op), operator: op), + isEditable: true + )) + } + + func removeRow(_ id: UUID) { + rows.removeAll { $0.id == id } + } + + func save() async { + guard let saved = await filters.save(filter, onServer: onServer) else { return } + + #if os(iOS) + UINotificationFeedbackGenerator().notificationOccurred(.success) + #endif + + onSave(saved) + dismiss() + } +} + +struct CustomFilterConditionRow: Identifiable { + let id = UUID() + var condition: CustomFilterCondition + /// `false` for conditions Ruddarr can't edit or check, which are saved back untouched. + let isEditable: Bool +} + +/// A condition Ruddarr can't check, shown read-only and saved back untouched. +struct CustomFilterUnsupportedSection: View { + let condition: CustomFilterCondition + let onDelete: () -> Void + + var body: some View { + Section { + LabeledContent("Field") { Text(verbatim: condition.key).textSelection(.enabled) } + LabeledContent("Operator") { Text(verbatim: condition.type).textSelection(.enabled) } + LabeledContent("Value") { + Text(verbatim: condition.value.displayString) + .textSelection(.enabled) + .multilineTextAlignment(.trailing) + } + } header: { + CustomFilterConditionHeader(summary: condition.rawSummary, onDelete: onDelete) + } footer: { + Text("\(Ruddarr.name) can't check this condition. It is kept when saving the filter.") + } + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFilterEditorSections.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFilterEditorSections.swift new file mode 100644 index 00000000..e6cd89bc --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFilterEditorSections.swift @@ -0,0 +1,103 @@ +import SwiftUI + +/// "Add Condition", a menu of fields in sections, and the live match count below it. +struct CustomFilterEditorAddSection: View { + let items: [Item] + let filter: CustomFilter + let onAdd: (CustomFilterField) -> Void + + /// How many items match the filled-in conditions, counted off the main thread. `nil` until first counted. + @State private var matching: Int? + + var body: some View { + Section { + Menu { + CustomFilterFieldPickerContent(fields: Item.customFilterFields) { field in + Button(field.label) { onAdd(field) } + } + } label: { + Label("Add Condition", systemImage: "plus.circle") + } + #if os(macOS) + .menuStyle(.borderlessButton) + .fixedSize() + #endif + .task(id: filter.conditions) { + await countMatches() + } + } footer: { + matchCount + } + } + + @ViewBuilder + var matchCount: some View { + if !filter.isSupported(for: Item.self) { + Text("Matches can't be counted, because \(Ruddarr.name) can't check every condition.") + } else if !filter.conditions.isEmpty, let matching { + Text(Self.matchCountText(matching, of: items.count)) + .contentTransition(.numericText()) + .animation(.default, value: matching) + } + } + + /// Recounts after a short pause, so typing a value doesn't count on every keystroke. + /// `.task(id:)` cancels a count whose conditions have since changed. + func countMatches() async { + guard filter.isSupported(for: Item.self), !filter.conditions.isEmpty else { return } + + if matching != nil { + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + } + + // Conditions without a value yet are left out, so the count follows what's filled in. + let counted = CustomFilter(id: filter.id, label: filter.label, conditions: filter.conditions.filter { !$0.value.isEmptyValue }) + let count = await Self.count(counted, in: items) + + guard !Task.isCancelled else { return } + matching = count + } + + @concurrent nonisolated private static func count(_ filter: CustomFilter, in items: [Item]) async -> Int { + let now = Date.now + let gregorian = Calendar.gregorian(in: .current) + + return items.count { filter.matches($0, now: now, calendar: gregorian) } + } + + static func matchCountText(_ matching: Int, of total: Int) -> String { + Item.self == Series.self + ? String(localized: "Matches \(matching) of \(total) series") + : String(localized: "Matches \(matching) of \(total) movies") + } +} + +/// Where the filter is saved: on the server, or on this device only. +struct CustomFilterEditorStorageSection: View { + let serverName: String + @Binding var onServer: Bool + /// Whether an existing server filter is being moved to this device. + let isMovingToDevice: Bool + + var body: some View { + Section { + Picker("Saved in", selection: $onServer) { + Text(verbatim: serverName).tag(true) + Text("This Device").tag(false) + } + .pickerStyle(.segmented) + .labelsHidden() + } header: { + Text("Saved in") + } footer: { + if isMovingToDevice { + Text("This filter will be removed from the \(serverName) web interface and other devices, and only saved on this device.") + } else if onServer { + Text("Also shows the filter in the \(serverName) web interface and in \(Ruddarr.name) on other devices.") + } else { + Text("Only shows the filter in \(Ruddarr.name) on this device.") + } + } + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFilterValueEditors.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFilterValueEditors.swift new file mode 100644 index 00000000..5f0e0a08 --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFilterValueEditors.swift @@ -0,0 +1,188 @@ +import SwiftUI + +/// One or more free-form text, number or size values, stored as an array. +struct CustomFilterListValueEditor: View { + let kind: CustomFilterValueKind + /// `false` for comparisons such as "is above", where a second value is meaningless. + let allowsMultiple: Bool + /// Whether the field's values are on a 0–100 scale, shown in the placeholder. + let isPercentScale: Bool + @Binding var value: CustomFilterValue + + @State private var entries: [Entry] + + struct Entry: Identifiable, Equatable { + let id = UUID() + var text: String + var unit: CustomFilterByteUnit = .gigabytes + } + + init(kind: CustomFilterValueKind, allowsMultiple: Bool, isPercentScale: Bool, value: Binding) { + self.kind = kind + self.allowsMultiple = allowsMultiple + self.isPercentScale = isPercentScale + self._value = value + + let entries = value.wrappedValue.arrayValues.compactMap { Self.entry(for: $0, kind: kind) } + self._entries = State(initialValue: entries.isEmpty ? [Entry(text: "")] : entries) + } + + var body: some View { + ForEach($entries) { $entry in + HStack { + TextField(prompt, text: $entry.text, prompt: Text(prompt)) + .labelsHidden() + #if os(iOS) + .keyboardType(kind == .text ? .default : .decimalPad) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + + if kind == .bytes { + Picker("Unit", selection: $entry.unit) { + ForEach(CustomFilterByteUnit.allCases) { unit in + Text(unit.label).tag(unit) + } + } + .labelsHidden() + .fixedSize() + } + + if entries.count > 1 { + Button("Remove Value", systemImage: "minus.circle.fill", role: .destructive) { + entries.removeAll { $0.id == entry.id } + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .foregroundStyle(.red) + } + } + // On every row, as at least one row always remains. + .onChange(of: entries) { + value = .array(entries.compactMap(parse)) + } + } + + if allowsMultiple { + Button("Add Value", systemImage: "plus") { + entries.append(Entry(text: "", unit: entries.last?.unit ?? .gigabytes)) + } + } + } + + var prompt: String { + switch kind { + case .bytes: String(localized: "Size") + case .number where isPercentScale: String(localized: "Number (0–100)") + case .number: String(localized: "Number") + default: String(localized: "Value") + } + } + + func parse(_ entry: Entry) -> CustomFilterValue? { + let text = entry.text.trimmed() + guard !text.isEmpty else { return nil } + + if kind == .text { + return .string(text) + } + + guard let number = Double(text.replacingOccurrences(of: ",", with: ".")), number.isFinite else { + return nil + } + + if kind == .bytes { + return .int(Int((number * entry.unit.multiplier).rounded())) + } + + if number == number.rounded(), let int = Int(exactly: number) { + return .int(int) + } + + return .double(number) + } + + static func entry(for value: CustomFilterValue, kind: CustomFilterValueKind) -> Entry? { + switch kind { + case .bytes: + guard let bytes = value.doubleValue else { return nil } + let unit = CustomFilterByteUnit.unit(for: bytes) + let amount = bytes / unit.multiplier + return Entry(text: amount.formatted(.number.grouping(.never).precision(.fractionLength(0...2))), unit: unit) + case .number: + guard let number = value.doubleValue else { return nil } + return Entry(text: number.formatted(.number.grouping(.never).precision(.fractionLength(0...6)))) + default: + return Entry(text: value.rawText) + } + } +} + +/// A date value: a `"yyyy-mm-dd"` string for before/after, or `{ time, value }` for in/not in last/next. +struct CustomFilterDateValueEditor: View { + let isRelative: Bool + @Binding var value: CustomFilterValue + + var body: some View { + if isRelative { + LabeledContent("Amount") { + TextField("Amount", value: amountBinding, format: .number) + .labelsHidden() + .multilineTextAlignment(.trailing) + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + + Picker("Unit", selection: unitBinding) { + ForEach(CustomFilterTimeUnit.allCases) { unit in + Text(unit.label).tag(unit) + } + } + } else { + DatePicker("Date", selection: dateBinding, displayedComponents: .date) + } + } + + var dateBinding: Binding { + Binding( + get: { + guard case .string(let string) = value else { return .now } + return CustomFilterDate.date(from: string) ?? .now + }, + set: { value = .string(CustomFilterDate.string(from: $0)) } + ) + } + + var object: [String: CustomFilterValue] { + if case .object(let object) = value { return object } + return [:] + } + + var amountBinding: Binding { + Binding( + get: { object["value"]?.intValue }, + set: { amount in + var object = object + object["value"] = amount.map { .int(max(0, $0)) } ?? .null + object["time"] = object["time"] ?? .string(CustomFilterTimeUnit.days.rawValue) + value = .object(object) + } + ) + } + + var unitBinding: Binding { + Binding( + get: { + guard case .string(let raw) = object["time"] else { return .days } + return CustomFilterTimeUnit(rawValue: raw) ?? .days + }, + set: { unit in + var object = object + object["time"] = .string(unit.rawValue) + object["value"] = object["value"] ?? .null + value = .object(object) + } + ) + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFiltersMenu.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFiltersMenu.swift new file mode 100644 index 00000000..84ea05d1 --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFiltersMenu.swift @@ -0,0 +1,100 @@ +import SwiftUI + +/// The Tag picker of the Movies and Series filter menus. +struct TagFilterPicker: View { + let tags: [Tag] + @Binding var selection: Int? + + var body: some View { + if !tags.isEmpty { + Picker(selection: $selection) { + Text("Any Tag").tag(Int?.none) + + ForEach(tags.sortedByLabel) { tag in + Text(tag.label).tag(Optional(tag.id)) + } + } label: { + if let label = tags.label(for: selection) { + Label(label, systemImage: "tag") + } else { + Label("Tag", systemImage: "tag") + } + } + .pickerStyle(.menu) + } + } +} + +/// The Custom Filters sections of the Movies and Series filter menus. +struct CustomFiltersMenuSection: View { + let filters: CustomFilters + /// "Radarr" or "Sonarr", the header of the server's filters. + let serverName: String + @Binding var selection: CustomFilter.ID? + let manage: () -> Void + + var body: some View { + let server = filters.serverFilters + let local = filters.appFilters + + if !server.isEmpty && !local.isEmpty { + Section(serverName) { + ForEach(server) { row($0) } + } + + Section("This Device") { + ForEach(local) { row($0) } + } + } else if !filters.all.isEmpty { + Section("Custom Filters") { + ForEach(filters.all) { row($0) } + } + } + + Section { + Button("Manage Filters…", systemImage: "slider.horizontal.3", action: manage) + } + } + + @ViewBuilder + func row(_ filter: CustomFilter) -> some View { + if filter.isSupported(for: Item.self) { + Toggle(filter.label, isOn: Binding( + get: { selection == filter.id }, + set: { selection = $0 ? filter.id : nil } + )) + } else { + // Manage Filters explains why the filter can't be applied. + Button(filter.label, systemImage: "exclamationmark.triangle", action: manage) + } + } +} + +extension View { + /// Presents the Manage Filters sheet. When `filters` change, clears `selection` if its filter is gone, otherwise calls `onChange`. + func customFiltersSheet( + isPresented: Binding, + selection: Binding, + in filters: CustomFilters, + onChange: @escaping () -> Void, + @ViewBuilder sheet: @escaping () -> Sheet + ) -> some View { + self + .onChange(of: filters.all) { + if let id = selection.wrappedValue, filters.byId(id) == nil { + // Clearing the selection changes the sort, which refreshes the grid. + selection.wrappedValue = nil + return + } + + onChange() + } + .sheet(isPresented: isPresented) { + sheet() + #if os(macOS) + .frame(minWidth: 460, minHeight: 480) + #endif + .presentationBackground(.sheetBackground) + } + } +} diff --git a/Ruddarr/Views/Shared/CustomFilters/CustomFiltersSheet.swift b/Ruddarr/Views/Shared/CustomFilters/CustomFiltersSheet.swift new file mode 100644 index 00000000..a199607c --- /dev/null +++ b/Ruddarr/Views/Shared/CustomFilters/CustomFiltersSheet.swift @@ -0,0 +1,276 @@ +import SwiftUI + +/// Lists an instance's custom filters, to create, edit and delete them. +struct CustomFiltersSheet: View { + let context: CustomFilterContext + @Binding var selection: CustomFilter.ID? + + @State private var editing: EditRequest? + @State private var filterToDelete: CustomFilter? + + @Environment(AppSettings.self) private var settings + @Environment(\.deviceType) private var deviceType + @Environment(\.dismiss) private var dismiss + + struct EditRequest: Identifiable { + let id = UUID() + let filter: CustomFilter + let isNew: Bool + /// Captured up front: while saving, the old id can disappear from `all` and clear the selection. + let selectAfterSaving: Bool + } + + var filters: CustomFilters { context.filters } + + /// The editor shows its own errors while it's open. + var alertBinding: Binding { + .init(get: { filters.error != nil && editing == nil }, set: { _ in }) + } + + var body: some View { + NavigationStack { + list + .sheet(item: $editing, content: editor) + .confirmationDialog( + "Delete Filter?", + isPresented: Binding(get: { filterToDelete != nil }, set: { if !$0 { filterToDelete = nil } }), + titleVisibility: .visible, + presenting: filterToDelete + ) { filter in + Button("Delete", role: .destructive) { + Task { await delete(filter) } + } + Button("Cancel", role: .cancel) { } + } message: { _ in + Text("This filter will also be removed from the \(context.serverName) web interface.") + } + .sensoryAlert(isPresented: alertBinding, error: filters.error) { _ in + Button("OK") { filters.error = nil } + } message: { error in + Text(error.recoverySuggestionFallback) + } + .tint(settings.theme.tint) + } + } + + var list: some View { + List { + serverSection + deviceSection + } + .customFilterSheetTitle("Custom Filters") + .safeNavigationBarTitleDisplayMode(.inline) + .overlay { + if filters.all.isEmpty { + emptyState + } + } + .refreshable { + _ = await filters.fetch(reportingErrors: true) + } + .toolbar { toolbar } + .horizontalToolbarOnly() + } + + func editor(_ request: EditRequest) -> some View { + CustomFilterEditor(context: context, filter: request.filter, isNew: request.isNew) { saved in + if request.selectAfterSaving { + selection = saved.id + } + } + #if os(macOS) + // The form's ideal height can exceed the window, so cap the sheet's ideal size. + .frame(minWidth: 520, minHeight: 400, idealHeight: 600) + #endif + .pageSized(deviceType == .pad) + .presentationBackground(.sheetBackground) + } + + @ViewBuilder + var serverSection: some View { + let server = filters.serverFilters + + if !server.isEmpty { + Section { + ForEach(server) { row($0) } + } header: { + Text(context.serverName) + } footer: { + Text("These filters also show in the \(context.serverName) web interface.") + } + } + } + + @ViewBuilder + var deviceSection: some View { + let local = filters.appFilters + + if !local.isEmpty { + Section { + ForEach(local) { row($0) } + } header: { + Text("This Device") + } footer: { + Text("These filters are only available in \(Ruddarr.name) on this device.") + } + } + } + + func row(_ filter: CustomFilter) -> some View { + Button { + edit(filter) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(filter.label) + .foregroundStyle(.primary) + + Text(filter.summary(for: Item.self)) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + + if !filter.isSupported(for: Item.self) { + Text("\(Ruddarr.name) can't check some of its conditions.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + if selection == filter.id { + Image(systemName: "checkmark") + .fontWeight(.semibold) + .foregroundStyle(.tint) + .accessibilityLabel("Selected") + } + + #if os(macOS) + Image(systemName: "chevron.forward") + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + #endif + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .swipeActions(edge: .trailing, allowsFullSwipe: !filter.isOnServer) { + Button("Delete", systemImage: "trash", role: .destructive) { + requestDelete(filter) + } + } + .contextMenu { + Button("Edit", systemImage: "pencil") { + edit(filter) + } + Button("Duplicate", systemImage: "plus.square.on.square") { + Task { await duplicate(filter) } + } + Button("Delete", systemImage: "trash", role: .destructive) { + requestDelete(filter) + } + } + } + + func edit(_ filter: CustomFilter) { + editing = EditRequest(filter: filter, isNew: false, selectAfterSaving: selection == filter.id) + } + + var emptyState: some View { + ContentUnavailableView { + Label("No Custom Filters", systemImage: "line.3.horizontal.decrease") + } description: { + Text("Create a filter here, or in the \(context.serverName) web interface.") + } actions: { + Button("New Filter") { + editing = EditRequest(filter: .new(), isNew: true, selectAfterSaving: true) + } + } + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + Button("Done", systemImage: "checkmark") { + dismiss() + } + .tint(.primary) + .hideIconOnMac() + } + + // macOS sheets don't show `.primaryAction`, so it goes in the bottom bar's leading slot. + ToolbarItem(placement: newFilterPlacement) { + Button("New Filter", systemImage: "plus") { + editing = EditRequest(filter: .new(), isNew: true, selectAfterSaving: true) + } + .tint(.primary) + .hideIconOnMac() + } + } + + var newFilterPlacement: ToolbarItemPlacement { + #if os(macOS) + .destructiveAction + #else + .primaryAction + #endif + } + + /// Server filters also disappear from the web UI, so they're confirmed first. + func requestDelete(_ filter: CustomFilter) { + if filter.isOnServer { + filterToDelete = filter + } else { + Task { await delete(filter) } + } + } + + /// Saves an on-device copy, which works even when the original is on the server. + func duplicate(_ filter: CustomFilter) async { + let copy = CustomFilter( + id: .app(UUID()), + label: String(localized: "\(filter.label) Copy", comment: "Name of a duplicated custom filter"), + conditions: filter.conditions + ) + + _ = await filters.save(copy, onServer: false) + } + + func delete(_ filter: CustomFilter) async { + guard await filters.delete(filter) else { return } + + if selection == filter.id { + selection = nil + } + } +} + +extension CustomFilter { + /// A one-line summary, e.g. "3 conditions · Monitored, Quality Profile, Added". + func summary(for _: Item.Type) -> String { + let count = conditions.count + let fields = conditions + .compactMap { condition in Item.customFilterFields.first { $0.key == condition.key }?.label } + .reduce(into: [String]()) { labels, label in + if !labels.contains(label) { labels.append(label) } + } + + let conditionCount = count == 1 + ? String(localized: "1 condition", comment: "Custom filter summary") + : String(localized: "\(count) conditions", comment: "Custom filter summary") + + return fields.isEmpty ? conditionCount : "\(conditionCount) · \(fields.joined(separator: ", "))" + } +} + +private extension View { + /// The editor shows more conditions in a page-sized sheet on iPad. + @ViewBuilder + func pageSized(_ condition: Bool) -> some View { + if condition { + presentationSizing(.page) + } else { + self + } + } +} diff --git a/Ruddarr/Views/Shared/Toolbar.swift b/Ruddarr/Views/Shared/Toolbar.swift index d17495ee..c17cfb15 100644 --- a/Ruddarr/Views/Shared/Toolbar.swift +++ b/Ruddarr/Views/Shared/Toolbar.swift @@ -60,6 +60,8 @@ struct ToolbarFilterBadge: View { struct ToolbarFilterLabel: View { var active: Bool var tint: Color + /// How many filters are active, read by VoiceOver. + var count: Int = 0 var body: some View { Group { @@ -76,6 +78,7 @@ struct ToolbarFilterLabel: View { Text("Filter").opacity(0) } .accessibilityLabel("Filter") + .accessibilityValue(count > 0 ? Text("\(count) active") : Text(verbatim: "")) } } diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index c4c39606..39e661a1 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -5,6 +5,8 @@ Added - Support Sonarr multi-season packs - Improved x265, H.266, VC1 and VVC codec replacements - Open the exact movie on Letterboxd instead of a search +- Filter movies and series by tag +- Use custom filters from the Radarr and Sonarr web interface, or create your own Changed - Show filter, sort and instance buttons together on folded iPhone Duo diff --git a/Tests/CustomFilterTests.swift b/Tests/CustomFilterTests.swift new file mode 100644 index 00000000..0fd8ec01 --- /dev/null +++ b/Tests/CustomFilterTests.swift @@ -0,0 +1,427 @@ +// swiftlint:disable file_length +import Testing +import Foundation + +// Exercises the custom filter wire format and the web UI's evaluation rules +// (Ruddarr/Models/CustomFilters). `Movie`/`Series` can't be compiled into this test bundle, +// so the rules run against `Item`, a fixture with the same kinds of values. +struct CustomFilterTests { + // MARK: - Fixtures + + private struct Item: CustomFilterable { + static let customFilterType = "test" + static var customFilterHiddenKeys: Set { ["missing"] } + static var customFilterFields: [CustomFilterField] { + [ + .init(key: "monitored", label: "Monitored", builder: .exact, kind: .bool), + .init(key: "status", label: "Status", builder: .exact, kind: .options([])), + .init(key: "title", label: "Title", builder: .string, kind: .text), + .init(key: "studio", label: "Studio", builder: .exact, kind: .libraryValues), + .init(key: "year", label: "Year", builder: .number, kind: .number), + .init(key: "rating", label: "Rating", builder: .number, kind: .number), + .init(key: "genres", label: "Genres", builder: .array, kind: .libraryValues), + .init(key: "tags", label: "Tags", builder: .array, kind: .tag), + .init(key: "added", label: "Added", builder: .date, kind: .date), + .init(key: "nextAiring", label: "Next Airing", builder: .date, kind: .date), + ] + } + + var values: [String: CustomFilterItemValue] + + func customFilterValue(for key: String) -> CustomFilterItemValue { + values[key] ?? .unsupported + } + } + + // 2024-06-15 12:00 UTC, evaluated in a fixed UTC-5 time zone. + private let now = Date(timeIntervalSince1970: 1_718_452_800) + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: -5 * 3_600)! + return calendar + }() + + private let item = Item(values: [ + "monitored": .bool(true), + "status": .string("released"), + "title": .string("The Matrix"), + "studio": .missing, + "year": .number(1_999), + "rating": .decimal(7.3, scale: 10), + "genres": .strings(["Action", "Science Fiction"]), + "tags": .numbers([1, 42]), + "added": .date(Date(timeIntervalSince1970: 1_718_452_800 - 10 * 86_400)), + "nextAiring": .date(nil), + "missing": .result(true), + ]) + + private func filter(_ conditions: CustomFilterCondition...) -> CustomFilter { + CustomFilter(id: .app(UUID()), label: "Test", conditions: conditions) + } + + private func matches(_ key: String, _ value: CustomFilterValue, _ type: String, _ item: Item? = nil) -> Bool { + filter(CustomFilterCondition(key: key, value: value, type: type)) + .matches(item ?? self.item, now: now, calendar: calendar) + } + + private func decode(_ json: String) throws -> CustomFilterResource { + try JSONDecoder().decode(CustomFilterResource.self, from: Data(json.utf8)) + } + + private func encode(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return try #require(String(bytes: encoder.encode(value), encoding: .utf8)) + } + + // MARK: - Wire format + + @Test func decodesSpecExamples() throws { + let radarr = try decode(#""" + { "id": 4, "type": "movieIndex", "label": "Recent 4K picks", "filters": [ + { "key": "monitored", "value": [true], "type": "equal" }, + { "key": "qualityProfileId", "value": [6], "type": "equal" }, + { "key": "added", "value": { "time": "days", "value": 30 }, "type": "inLast" }, + { "key": "tags", "value": [2, 5], "type": "contains" }, + { "key": "imdbRating", "value": [7.5], "type": "greaterThanOrEqual" } + ] } + """#) + + #expect(radarr.customFilter?.id == .server(4)) + #expect(radarr.type == "movieIndex") + #expect(radarr.label == "Recent 4K picks") + #expect(radarr.filters.map(\.value) == [ + .array([.bool(true)]), + .array([.int(6)]), + .object(["time": .string("days"), "value": .int(30)]), + .array([.int(2), .int(5)]), + .array([.double(7.5)]), + ]) + #expect(radarr.filters[2].operator == .inLast) + + let bigOld = try decode(#""" + { "id": 7, "type": "movieIndex", "label": "Big old non-horror", "filters": [ + { "key": "releaseDate", "value": "2000-01-01", "type": "lessThan" }, + { "key": "genres", "value": ["Horror", "Comedy"], "type": "notContains" }, + { "key": "sizeOnDisk", "value": [21474836480], "type": "greaterThan" }, + { "key": "status", "value": ["released"], "type": "equal" } + ] } + """#) + + #expect(bigOld.filters[0].value == .string("2000-01-01")) + #expect(bigOld.filters[2].value == .array([.int(21_474_836_480)])) + + let sonarr = try decode(#""" + { "id": 2, "type": "series", "label": "Anime gaps", "filters": [ + { "key": "status", "value": ["continuing", "upcoming"], "type": "equal" }, + { "key": "seriesType", "value": ["anime"], "type": "equal" }, + { "key": "hasMissingSeason", "value": [true], "type": "equal" }, + { "key": "seasonsMonitoredStatus", "value": ["all"], "type": "equal" }, + { "key": "title", "value": ["the"], "type": "notStartsWith" } + ] } + """#) + + #expect(sonarr.filters.count == 5) + #expect(sonarr.filters[0].value == .array([.string("continuing"), .string("upcoming")])) + #expect(sonarr.filters[4].operator == .notStartsWith) + } + + @Test func missingTypeMeansEqualAndMissingValueMeansNull() throws { + let resource = try decode(#"{ "id": 1, "type": "movieIndex", "label": "A", "filters": [{ "key": "monitored" }] }"#) + + #expect(resource.filters[0].type == "equal") + #expect(resource.filters[0].value == .null) + } + + @Test func encodingPreservesIntsUnknownOperatorsAndUnsupportedConditions() throws { + let json = #"{"filters":["# + + #"{"key":"tags","type":"contains","value":[2,5]},"# + + #"{"key":"keywords","type":"contains","value":["heist"]},"# + + #"{"key":"year","type":"betweenish","value":[1.5]}"# + + #"],"id":3,"label":"A","type":"movieIndex"}"# + + #expect(try encode(decode(json)) == json) + } + + @Test func encodingANewFilterOmitsTheId() throws { + let filter = CustomFilter(id: .app(UUID()), label: "New", conditions: [ + CustomFilterCondition(key: "qualityProfileId", value: .array([.int(4)]), operator: .equal), + ]) + + let json = try encode(CustomFilterResource(filter, type: "series")) + + #expect(json == #"{"filters":[{"key":"qualityProfileId","type":"equal","value":[4]}],"label":"New","type":"series"}"#) + } + + @Test func customFilterRoundTripsThroughTheCache() throws { + let original = CustomFilter(id: .app(UUID()), label: "Mine", conditions: [ + CustomFilterCondition(key: "added", value: .object(["time": .string("weeks"), "value": .null]), operator: .inLast), + CustomFilterCondition(key: "custom", value: .array([.bool(false)]), type: "someFutureOperator"), + ]) + + let decoded = try JSONDecoder().decode(CustomFilter.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + + let server = CustomFilter(id: .server(9), label: "Theirs", conditions: []) + #expect(try JSONDecoder().decode(CustomFilter.self, from: JSONEncoder().encode(server)) == server) + } + + @Test func lossyListDropsMalformedEntries() throws { + let json = #""" + [ + { "id": 1, "type": "movieIndex", "label": "Good", "filters": [] }, + { "id": 2, "type": "movieIndex", "label": "No filters" }, + { "id": 3, "type": "history", "label": "Other", "filters": [{ "key": "eventType", "value": 4 }] } + ] + """# + + let filters = try JSONDecoder().decode([LossyDecoded].self, from: Data(json.utf8)) + .compactMap(\.wrappedValue) + + #expect(filters.map(\.id) == [1, 3]) + } + + // MARK: - Filter loop + + @Test func emptyFilterMatchesEverything() { + #expect(filter().matches(item, now: now, calendar: calendar)) + } + + @Test func conditionsAreANDed() { + let both = filter( + CustomFilterCondition(key: "monitored", value: .array([.bool(true)]), operator: .equal), + CustomFilterCondition(key: "year", value: .array([.int(2_000)]), operator: .lessThan) + ) + let one = filter( + CustomFilterCondition(key: "monitored", value: .array([.bool(true)]), operator: .equal), + CustomFilterCondition(key: "year", value: .array([.int(1_990)]), operator: .lessThan) + ) + + #expect(both.matches(item, now: now, calendar: calendar)) + #expect(!one.matches(item, now: now, calendar: calendar)) + } + + @Test func arrayValuesNeedAnyElementForPositiveOperators() { + #expect(matches("tags", .array([.int(7), .int(42)]), "contains")) + #expect(!matches("tags", .array([.int(7), .int(8)]), "contains")) + #expect(matches("status", .array([.string("tba"), .string("released")]), "equal")) + #expect(matches("year", .array([.int(2_010), .int(1_990)]), "greaterThan")) + } + + @Test func arrayValuesNeedEveryElementForNegativeOperators() { + #expect(matches("genres", .array([.string("Horror"), .string("Comedy")]), "notContains")) + #expect(!matches("genres", .array([.string("Horror"), .string("Action")]), "notContains")) + #expect(matches("status", .array([.string("tba"), .string("deleted")]), "notEqual")) + #expect(!matches("status", .array([.string("tba"), .string("released")]), "notEqual")) + } + + @Test func emptyArrayRejectsPositiveAndAcceptsNegativeOperators() { + #expect(!matches("tags", .array([]), "contains")) + #expect(!matches("status", .array([]), "equal")) + #expect(matches("tags", .array([]), "notContains")) + #expect(matches("status", .array([]), "notEqual")) + } + + @Test func scalarValuesAreTestedOnce() { + #expect(matches("monitored", .bool(true), "equal")) + #expect(!matches("monitored", .bool(false), "equal")) + #expect(matches("tags", .int(42), "contains")) + } + + // MARK: - Generic predicates + + @Test func equalityIsStrict() { + #expect(!matches("tags", .array([.string("1")]), "contains")) + #expect(!matches("year", .array([.string("1999")]), "equal")) + #expect(!matches("monitored", .array([.string("true")]), "equal")) + #expect(!matches("monitored", .array([.int(1)]), "equal")) + #expect(matches("monitored", .array([.string("true")]), "notEqual")) + #expect(matches("year", .array([.double(1_999.0)]), "equal")) + #expect(matches("tags", .array([.double(42.0)]), "contains")) + } + + @Test func stringEqualityIsCaseSensitive() { + #expect(matches("title", .array([.string("The Matrix")]), "equal")) + #expect(!matches("title", .array([.string("the matrix")]), "equal")) + #expect(matches("title", .array([.string("the matrix")]), "notEqual")) + } + + @Test func substringOperatorsAreCaseInsensitive() { + #expect(matches("title", .array([.string("MATRIX")]), "contains")) + #expect(!matches("title", .array([.string("matrix")]), "notContains")) + #expect(matches("title", .array([.string("the")]), "startsWith")) + #expect(!matches("title", .array([.string("the")]), "notStartsWith")) + #expect(matches("title", .array([.string("RIX")]), "endsWith")) + #expect(matches("title", .array([.string("neo")]), "notEndsWith")) + #expect(matches("title", .array([.string("")]), "contains")) + } + + @Test func itemArraysUseStrictMembership() { + #expect(matches("genres", .array([.string("Action")]), "contains")) + #expect(!matches("genres", .array([.string("action")]), "contains")) + #expect(!matches("genres", .array([.string("Science")]), "contains")) + } + + @Test func stringOperatorsOnNonStringsFail() { + // The web UI throws on `toLowerCase`, so the condition fails either way. + #expect(!matches("year", .array([.int(19)]), "startsWith")) + #expect(!matches("year", .array([.int(19)]), "notStartsWith")) + #expect(!matches("title", .array([.int(1)]), "contains")) + #expect(!matches("title", .array([.int(1)]), "notContains")) + } + + @Test func numberComparisons() { + #expect(matches("year", .array([.int(1_999)]), "greaterThanOrEqual")) + #expect(!matches("year", .array([.int(1_999)]), "greaterThan")) + #expect(matches("year", .array([.int(1_999)]), "lessThanOrEqual")) + #expect(matches("year", .array([.int(2_000)]), "lessThan")) + #expect(matches("year", .array([.int(1_998)]), "notEqual")) + } + + @Test func ratingsAreScaledByTen() { + #expect(matches("rating", .array([.int(73)]), "equal")) + #expect(matches("rating", .array([.int(70)]), "greaterThan")) + #expect(!matches("rating", .array([.int(74)]), "greaterThanOrEqual")) + #expect(CustomFilterItemValue.decimal(0, scale: 10).isNumber(0)) + } + + // MARK: - Item values + + @Test func missingValuesRejectTheItem() { + #expect(!matches("studio", .array([.string("Warner")]), "equal")) + #expect(!matches("studio", .array([.string("Warner")]), "notEqual")) + #expect(!matches("studio", .array([]), "notContains")) + } + + @Test func unsupportedValuesFailTheCondition() { + #expect(!matches("keywords", .array([.string("heist")]), "contains")) + #expect(!matches("keywords", .array([.string("heist")]), "notContains")) + } + + @Test func resultValuesIgnoreValueAndOperator() { + #expect(matches("missing", .array([.bool(false)]), "equal")) + #expect(matches("missing", .bool(true), "notEqual")) + #expect(!matches("missing", .array([.bool(true)]), "equal", Item(values: ["missing": .result(false)]))) + // The predicate still runs once per element, so an empty array follows the array rules. + #expect(!matches("missing", .array([]), "equal")) + #expect(matches("missing", .array([]), "notEqual", Item(values: ["missing": .result(false)]))) + } + + @Test func unknownOperatorsFail() { + #expect(!matches("title", .array([.string("The Matrix")]), "fuzzyEquals")) + } + + // MARK: - Dates + + @Test func absoluteDatesAreLocalMidnightAndStrict() { + // 2024-06-05 05:00 UTC is exactly local midnight on June 5 in UTC-5. + let midnight = Item(values: ["added": .date(Date(timeIntervalSince1970: 1_717_563_600))]) + + #expect(!matches("added", .string("2024-06-05"), "lessThan", midnight)) + #expect(!matches("added", .string("2024-06-05"), "greaterThan", midnight)) + #expect(matches("added", .string("2024-06-06"), "lessThan", midnight)) + #expect(matches("added", .string("2024-06-04"), "greaterThan", midnight)) + + let justBefore = Item(values: ["added": .date(Date(timeIntervalSince1970: 1_717_563_599))]) + #expect(matches("added", .string("2024-06-05"), "lessThan", justBefore)) + } + + @Test func relativeDates() { + let last30 = CustomFilterValue.object(["time": .string("days"), "value": .int(30)]) + let last7 = CustomFilterValue.object(["time": .string("days"), "value": .int(7)]) + + // `added` is 10 days ago. + #expect(matches("added", last30, "inLast")) + #expect(!matches("added", last7, "inLast")) + #expect(matches("added", last7, "notInLast")) + #expect(!matches("added", last30, "notInLast")) + #expect(!matches("added", last30, "inNext")) + #expect(!matches("added", last30, "notInNext")) + #expect(matches("added", .object(["time": .string("weeks"), "value": .int(2)]), "inLast")) + #expect(!matches("added", .object(["time": .string("hours"), "value": .int(48)]), "inLast")) + } + + @Test func futureDatesAreNotInLast() { + let inFiveDays = Item(values: ["added": .date(now.addingTimeInterval(5 * 86_400))]) + let days = { (amount: Int) in CustomFilterValue.object(["time": .string("days"), "value": .int(amount)]) } + + #expect(!matches("added", days(30), "inLast", inFiveDays)) + #expect(!matches("added", days(30), "notInLast", inFiveDays)) + #expect(matches("added", days(30), "inNext", inFiveDays)) + #expect(!matches("added", days(3), "inNext", inFiveDays)) + #expect(matches("added", days(3), "notInNext", inFiveDays)) + #expect(!matches("added", days(30), "notInNext", inFiveDays)) + } + + @Test func monthsAreCalendarAware() { + // 2024-03-31 12:00 UTC minus one month clamps to 2024-02-29 in the local calendar. + let march31 = Date(timeIntervalSince1970: 1_711_886_400) + let month = CustomFilterRelativeDate(unit: .months, amount: 1) + let bound = month.date(from: march31, forward: false, calendar: calendar) + + #expect(bound == Date(timeIntervalSince1970: 1_709_208_000)) + } + + @Test func nullRelativeValueMeansZero() { + let zero = CustomFilterValue.object(["time": .string("seconds"), "value": .null]) + + #expect(matches("added", zero, "notInLast")) + #expect(!matches("added", zero, "inLast")) + } + + @Test func nilDatesNeverMatch() { + let days = CustomFilterValue.object(["time": .string("days"), "value": .int(30)]) + + for type in ["inLast", "notInLast", "inNext", "notInNext"] { + #expect(!matches("nextAiring", days, type)) + } + + #expect(!matches("nextAiring", .string("2030-01-01"), "lessThan")) + #expect(!matches("nextAiring", .string("2000-01-01"), "greaterThan")) + } + + @Test func otherOperatorsOnDatesFail() { + #expect(!matches("added", .string("2024-06-05"), "equal")) + } + + // MARK: - Support + + @Test func supportedFilter() { + let supported = filter( + CustomFilterCondition(key: "tags", value: .array([.int(1)]), operator: .contains), + CustomFilterCondition(key: "added", value: .object(["time": .string("months"), "value": .int(3)]), operator: .notInLast), + CustomFilterCondition(key: "added", value: .string("2024-02-29"), operator: .greaterThan), + CustomFilterCondition(key: "missing", value: .bool(true), operator: .equal) + ) + + #expect(supported.isSupported(for: Item.self)) + } + + @Test func unsupportedConditions() { + let unknownKey = CustomFilterCondition(key: "keywords", value: .array([.string("heist")]), operator: .contains) + let unknownOperator = CustomFilterCondition(key: "title", value: .array([.string("a")]), type: "fuzzy") + let badDate = CustomFilterCondition(key: "added", value: .string("2024-02-30"), operator: .lessThan) + let looseDate = CustomFilterCondition(key: "added", value: .string("2024-2-3"), operator: .lessThan) + let badUnit = CustomFilterCondition(key: "added", value: .object(["time": .string("years"), "value": .int(1)]), operator: .inLast) + let relativeOnNumber = CustomFilterCondition(key: "year", value: .object(["time": .string("days"), "value": .int(1)]), operator: .inLast) + let objectValue = CustomFilterCondition(key: "year", value: .array([.object([:])]), operator: .equal) + let fine = CustomFilterCondition(key: "year", value: .array([.int(2_000)]), operator: .lessThan) + + let mixed = filter(unknownKey, unknownOperator, badDate, looseDate, badUnit, relativeOnNumber, objectValue, fine) + + #expect(mixed.unsupportedConditions(for: Item.self) == [unknownKey, unknownOperator, badDate, looseDate, badUnit, relativeOnNumber, objectValue]) + #expect(!mixed.isSupported(for: Item.self)) + } + + @Test func relativeDateEncodesWholeNumbersAsInts() { + #expect(CustomFilterRelativeDate(unit: .days, amount: 30).value == .object(["time": .string("days"), "value": .int(30)])) + #expect(CustomFilterRelativeDate(.object(["time": .string("hours"), "value": .double(1.5)]))?.amount == 1.5) + } +} + +private extension CustomFilterItemValue { + func isNumber(_ expected: Double) -> Bool { + if case .number(let value) = self { return value == expected } + return false + } +}