From d92c31c012fd222dc50e1f8122b0ee22a41a87be Mon Sep 17 00:00:00 2001 From: Sam Porter <29922188+samuelporter@users.noreply.github.com> Date: Sun, 17 May 2026 20:36:29 -0500 Subject: [PATCH 1/2] Add Prowlarr instance support --- Ruddarr.xcodeproj/project.pbxproj | 20 +++ Ruddarr/Dependencies/API/API+Live.swift | 20 ++- Ruddarr/Dependencies/API/API+Mock.swift | 4 + Ruddarr/Dependencies/API/API.swift | 3 + Ruddarr/Dependencies/Toast.swift | 6 + Ruddarr/Localizable.xcstrings | 140 ++++++++++++++++++ Ruddarr/Models/Instances/Indexer.swift | 77 ++++++++++ Ruddarr/Models/Instances/Instance.swift | 15 ++ .../Models/Instances/ProwlarrInstance.swift | 57 +++++++ .../Preview Content/prowlarr-indexers.json | 70 +++++++++ Ruddarr/Services/AppSettings.swift | 15 +- Ruddarr/Services/Notifications.swift | 2 +- Ruddarr/Utilities/View.swift | 2 +- Ruddarr/Views/Activity/HistoryView.swift | 2 +- Ruddarr/Views/ActivityView.swift | 2 +- Ruddarr/Views/CalendarView.swift | 4 +- .../Views/Settings/IndexerDetailView.swift | 79 ++++++++++ Ruddarr/Views/Settings/IndexersView.swift | 105 +++++++++++++ .../Settings/InstanceEditView+Functions.swift | 4 + Ruddarr/Views/Settings/InstanceEditView.swift | 1 + Ruddarr/Views/Settings/InstanceRow.swift | 49 ++++-- .../Settings/InstanceView+Notifications.swift | 2 + Ruddarr/Views/Settings/InstanceView.swift | 12 +- Ruddarr/Views/SettingsView.swift | 12 +- 24 files changed, 669 insertions(+), 34 deletions(-) create mode 100644 Ruddarr/Models/Instances/Indexer.swift create mode 100644 Ruddarr/Models/Instances/ProwlarrInstance.swift create mode 100644 Ruddarr/Preview Content/prowlarr-indexers.json create mode 100644 Ruddarr/Views/Settings/IndexerDetailView.swift create mode 100644 Ruddarr/Views/Settings/IndexersView.swift diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 32194856..237ca387 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 22607C692FB96A32003C5955 /* Indexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C682FB96A32003C5955 /* Indexer.swift */; }; + 22607C6D2FB96E58003C5955 /* prowlarr-indexers.json in Resources */ = {isa = PBXBuildFile; fileRef = 22607C6C2FB96E58003C5955 /* prowlarr-indexers.json */; }; + 22607C6F2FB96F27003C5955 /* ProwlarrInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C6E2FB96F27003C5955 /* ProwlarrInstance.swift */; }; + 22607C712FB97253003C5955 /* IndexersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C702FB97253003C5955 /* IndexersView.swift */; }; + 22607C732FB972BE003C5955 /* IndexerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C722FB972BE003C5955 /* IndexerDetailView.swift */; }; 2B949CE52CC92CA20088B1A8 /* sonarr-history.json in Resources */ = {isa = PBXBuildFile; fileRef = 2B949CE42CC92C970088B1A8 /* sonarr-history.json */; }; 2B949CE72CC92F370088B1A8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B949CE62CC92F320088B1A8 /* History.swift */; }; 2B949CEB2CCBC8690088B1A8 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B949CEA2CCBC8600088B1A8 /* HistoryView.swift */; }; @@ -261,6 +266,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 22607C682FB96A32003C5955 /* Indexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Indexer.swift; sourceTree = ""; }; + 22607C6C2FB96E58003C5955 /* prowlarr-indexers.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "prowlarr-indexers.json"; sourceTree = ""; }; + 22607C6E2FB96F27003C5955 /* ProwlarrInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrInstance.swift; sourceTree = ""; }; + 22607C702FB97253003C5955 /* IndexersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexersView.swift; sourceTree = ""; }; + 22607C722FB972BE003C5955 /* IndexerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexerDetailView.swift; sourceTree = ""; }; 2B949CE42CC92C970088B1A8 /* sonarr-history.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "sonarr-history.json"; sourceTree = ""; }; 2B949CE62CC92F320088B1A8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; 2B949CEA2CCBC8600088B1A8 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; @@ -580,6 +590,8 @@ BBFDEB832BD5B78B009F002F /* SettingsLinksSection.swift */, BB6F23AC2B6ABBBD00A4347A /* SettingsSystemSection.swift */, BBFFB50F2D72717000AB5EFD /* BugSheet.swift */, + 22607C702FB97253003C5955 /* IndexersView.swift */, + 22607C722FB972BE003C5955 /* IndexerDetailView.swift */, ); path = Settings; sourceTree = ""; @@ -912,6 +924,7 @@ BB2370D92DCE657800261710 /* sonarr-manual-import.json */, BBA06B582F47D4B600A5F9B4 /* popular-movies.json */, BB181D6E2F48E8DF00981037 /* popular-series.json */, + 22607C6C2FB96E58003C5955 /* prowlarr-indexers.json */, ); path = "Preview Content"; sourceTree = ""; @@ -971,6 +984,8 @@ BB8A60252B93DE020039CFF6 /* InstanceNotification.swift */, BBE7F8A82B6424350014DD57 /* RadarrInstance.swift */, BB507D1E2BD96EEB00EC4016 /* SonarrInstance.swift */, + 22607C6E2FB96F27003C5955 /* ProwlarrInstance.swift */, + 22607C682FB96A32003C5955 /* Indexer.swift */, ); path = Instances; sourceTree = ""; @@ -1155,6 +1170,7 @@ BBE1E4432B51FA0200946222 /* movie-lookup.json in Resources */, BB2370D82DCE657700261710 /* radarr-manual-import.json in Resources */, BB81000A2CD5977600499666 /* radarr-history.json in Resources */, + 22607C6D2FB96E58003C5955 /* prowlarr-indexers.json in Resources */, BBC0E6202C02458E009543E3 /* series-queue.json in Resources */, BBA7336B2BB6261D00A5022B /* movie-files.json in Resources */, BB6A5D382E3ABB23002E036D /* tags.json in Resources */, @@ -1228,13 +1244,16 @@ 79159D6E2B5953E800F7F997 /* API.swift in Sources */, BB05C9052B86D0EC009B6444 /* Languages.swift in Sources */, BBC136A42B62DD780074C7AA /* Network.swift in Sources */, + 22607C6F2FB96F27003C5955 /* ProwlarrInstance.swift in Sources */, BB89ABC62B756B91009FB62D /* MovieReleaseRow.swift in Sources */, BB77C2CC2C19F41800125852 /* SeriesDefaults.swift in Sources */, BB7DDF652B718DFB0001CDFC /* MovieReleaseSheet.swift in Sources */, + 22607C732FB972BE003C5955 /* IndexerDetailView.swift in Sources */, 79B761CB2B7115EC001DD30E /* Toast.swift in Sources */, BB507D302BD99A0500EC4016 /* SeriesDetails.swift in Sources */, BBA733652BB4F66600A5022B /* MovieMetadata.swift in Sources */, BB456D232B58C71300C29B00 /* NoInternet.swift in Sources */, + 22607C712FB97253003C5955 /* IndexersView.swift in Sources */, BB3EBC012CC052B700141868 /* TaskRemovalView.swift in Sources */, BBF706BC2B6220BF00B2B504 /* Sentry.swift in Sources */, BB0C3EC72BF90C5900632CB1 /* NoInstance.swift in Sources */, @@ -1336,6 +1355,7 @@ BBF94F632B508F8B00300EBA /* MovieView.swift in Sources */, BBFDEB842BD5B78B009F002F /* SettingsLinksSection.swift in Sources */, BB8A602A2B97840F0039CFF6 /* InstanceView+Notifications.swift in Sources */, + 22607C692FB96A32003C5955 /* Indexer.swift in Sources */, BBBCA0582BE936F300BAE374 /* Season.swift in Sources */, BB507D322BD99C0300EC4016 /* SeriesDetails+Overview.swift in Sources */, BB9BF4182DCFFE2D00488FCA /* MediaGrid.swift in Sources */, diff --git a/Ruddarr/Dependencies/API/API+Live.swift b/Ruddarr/Dependencies/API/API+Live.swift index 694c7553..d117722d 100644 --- a/Ruddarr/Dependencies/API/API+Live.swift +++ b/Ruddarr/Dependencies/API/API+Live.swift @@ -227,8 +227,8 @@ extension API { return try await request(method: .post, url: url, headers: instance.auth, body: payload, timeout: instance.timeout(.releaseDownload)) }, systemStatus: { instance in - let url = try instance.baseURL() - .appending(path: "/api/v3/system/status") + let path = instance.type == .prowlarr ? "/api/v1/system/status" : "/api/v3/system/status" + let url = try instance.baseURL().appending(path: path) return try await request(url: url, headers: instance.auth) }, rootFolders: { instance in @@ -241,8 +241,8 @@ extension API { .appending(path: "/api/v3/qualityprofile") return try await request(url: url, headers: instance.auth) }, getTags: { instance in - let url = try instance.baseURL() - .appending(path: "/api/v3/tag") + let path = instance.type == .prowlarr ? "/api/v1/tag" : "/api/v3/tag" + let url = try instance.baseURL().appending(path: path) return try await request(url: url, headers: instance.auth) }, fetchQueueTasks: { instance in @@ -315,6 +315,18 @@ extension API { .appending(path: String(model.id ?? 0)) return try await request(method: .delete, url: url, headers: instance.auth) + }, fetchIndexers: { instance in + let url = try instance.baseURL() + .appending(path: "/api/v1/indexer") + + return try await request(url: url, headers: instance.auth, timeout: instance.timeout(.normal)) + }, setIndexersEnabled: { ids, enable, instance in + let url = try instance.baseURL() + .appending(path: "/api/v1/indexer/bulk") + + let body = IndexerBulkResource(ids: ids, enable: enable) + + return try await request(method: .put, url: url, headers: instance.auth, body: body) }) } } diff --git a/Ruddarr/Dependencies/API/API+Mock.swift b/Ruddarr/Dependencies/API/API+Mock.swift index d2c13f27..09d56ea1 100644 --- a/Ruddarr/Dependencies/API/API+Mock.swift +++ b/Ruddarr/Dependencies/API/API+Mock.swift @@ -198,6 +198,10 @@ extension API { try await Task.sleep(for: .seconds(2)) return Empty() + }, fetchIndexers: { _ in + loadPreviewData(filename: "prowlarr-indexers") + }, setIndexersEnabled: { _, _, _ in + Empty() }) } } diff --git a/Ruddarr/Dependencies/API/API.swift b/Ruddarr/Dependencies/API/API.swift index 34f5a4d7..c0a9e02d 100644 --- a/Ruddarr/Dependencies/API/API.swift +++ b/Ruddarr/Dependencies/API/API.swift @@ -54,6 +54,9 @@ struct API { var createNotification: (InstanceNotification, Instance) async throws -> InstanceNotification var updateNotification: (InstanceNotification, Instance) async throws -> InstanceNotification var deleteNotification: (InstanceNotification, Instance) async throws -> Empty + + var fetchIndexers: (Instance) async throws -> [Indexer] + var setIndexersEnabled: (_ ids: [Int], _ enable: Bool, Instance) async throws -> Empty } extension API { diff --git a/Ruddarr/Dependencies/Toast.swift b/Ruddarr/Dependencies/Toast.swift index 1a8cb182..4ff7e586 100644 --- a/Ruddarr/Dependencies/Toast.swift +++ b/Ruddarr/Dependencies/Toast.swift @@ -73,6 +73,8 @@ extension Toast { enum PresetMessage { case monitored case unmonitored + case indexerEnabled + case indexerDisabled case importQueued case refreshQueued case downloadQueued @@ -96,6 +98,10 @@ extension Toast { notice(text: String(localized: "Monitored"), icon: "bookmark.fill") case .unmonitored: notice(text: String(localized: "Unmonitored"), icon: "bookmark") + case .indexerEnabled: + notice(text: String(localized: "Indexer Enabled"), icon: "checkmark.circle.fill") + case .indexerDisabled: + notice(text: String(localized: "Indexer Disabled"), icon: "checkmark.circle.fill") case .refreshQueued: notice(text: String(localized: "Refresh Queued"), icon: "checkmark.circle.fill") case .importQueued: diff --git a/Ruddarr/Localizable.xcstrings b/Ruddarr/Localizable.xcstrings index 0e13d4d2..d3b14286 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -368,6 +368,16 @@ } } }, + "Add indexers in the Prowlarr web interface." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add indexers in the Prowlarr web interface." + } + } + } + }, "Add Instance" : { "localizations" : { "en" : { @@ -938,6 +948,16 @@ } } }, + "Categories" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Categories" + } + } + } + }, "Channels" : { "comment" : "Audio channel count", "localizations" : { @@ -1311,6 +1331,16 @@ } } }, + "Description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Description" + } + } + } + }, "Details" : { "localizations" : { "en" : { @@ -2164,6 +2194,46 @@ } } }, + "Indexer Disabled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indexer Disabled" + } + } + } + }, + "Indexer Enabled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indexer Enabled" + } + } + } + }, + "Indexers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indexers" + } + } + } + }, + "Indexers could not be loaded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indexers could not be loaded" + } + } + } + }, "Information" : { "localizations" : { "en" : { @@ -2887,6 +2957,16 @@ } } }, + "Name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + } + } + }, "Network" : { "comment" : "The network that airs the show", "localizations" : { @@ -2988,6 +3068,16 @@ } } }, + "No Indexers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Indexers" + } + } + } + }, "No Instance" : { "localizations" : { "en" : { @@ -3957,6 +4047,36 @@ } } }, + "Priority" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Priority" + } + } + } + }, + "Privacy" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy" + } + } + } + }, + "Private" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Private" + } + } + } + }, "Proper" : { "comment" : "The PROPER flag", "localizations" : { @@ -3988,6 +4108,16 @@ } } }, + "Public" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Public" + } + } + } + }, "Published" : { "comment" : "Release publish date", "localizations" : { @@ -4551,6 +4681,16 @@ } } }, + "Semi-Private" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Semi-Private" + } + } + } + }, "Sending push notifications to devices requires reliable server infrastructure, which incurs monthly operating expenses for this free, open-source project." : { "localizations" : { "en" : { diff --git a/Ruddarr/Models/Instances/Indexer.swift b/Ruddarr/Models/Instances/Indexer.swift new file mode 100644 index 00000000..2fd7e38e --- /dev/null +++ b/Ruddarr/Models/Instances/Indexer.swift @@ -0,0 +1,77 @@ +import Foundation + +struct Indexer: Identifiable, Equatable, Hashable, Codable { + let id: Int + let name: String + let definitionName: String? + let description: String? + var enable: Bool + let `protocol`: IndexerProtocol + let privacy: IndexerPrivacy + let priority: Int + let language: String? + let added: Date? + let appProfileId: Int? + let tags: [Int] + let capabilities: IndexerCapabilities? +} + +enum IndexerProtocol: String, Codable, Hashable { + case torrent + case usenet + case unknown + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + self = IndexerProtocol(rawValue: raw) ?? .unknown + } + + var label: String { + switch self { + case .torrent: String(localized: "Torrent") + case .usenet: String(localized: "Usenet") + case .unknown: String(localized: "Unknown") + } + } +} + +enum IndexerPrivacy: String, Codable, Hashable { + case `public` + case `private` + case semiPrivate + case unknown + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + self = IndexerPrivacy(rawValue: raw) ?? .unknown + } + + var label: String { + switch self { + case .public: String(localized: "Public") + case .private: String(localized: "Private") + case .semiPrivate: String(localized: "Semi-Private") + case .unknown: String(localized: "Unknown") + } + } +} + +struct IndexerCapabilities: Codable, Equatable, Hashable { + let limitsMax: Int? + let limitsDefault: Int? + let categories: [IndexerCategory]? + let supportsRawSearch: Bool? +} + +struct IndexerCategory: Codable, Equatable, Hashable, Identifiable { + let id: Int + let name: String + let subCategories: [IndexerCategory]? +} + +struct IndexerBulkResource: Codable { + let ids: [Int] + let enable: Bool +} diff --git a/Ruddarr/Models/Instances/Instance.swift b/Ruddarr/Models/Instances/Instance.swift index 6f5f2baf..4af8f87d 100644 --- a/Ruddarr/Models/Instances/Instance.swift +++ b/Ruddarr/Models/Instances/Instance.swift @@ -82,6 +82,7 @@ struct Instance: Identifiable, Equatable, Codable { enum InstanceType: String, Identifiable, CaseIterable, Codable { case radarr = "Radarr" case sonarr = "Sonarr" + case prowlarr = "Prowlarr" var id: Self { self } } @@ -218,4 +219,18 @@ extension Instance { return instance } + + static var prowlarrDummy: Self { + var instance = Instance(id: UUID(uuidString: "00000000-5000-0000-0000-000000000000")!) + + instance.type = .prowlarr + instance.label = ".prowlarr" + instance.url = "http://10.0.1.5:9696" + instance.apiKey = "deadbeefcafebabe1234567890abcdef" + instance.tags = [ + Tag(id: 1, label: "Anime"), + ] + + return instance + } } diff --git a/Ruddarr/Models/Instances/ProwlarrInstance.swift b/Ruddarr/Models/Instances/ProwlarrInstance.swift new file mode 100644 index 00000000..6b4c87d5 --- /dev/null +++ b/Ruddarr/Models/Instances/ProwlarrInstance.swift @@ -0,0 +1,57 @@ +import os +import SwiftUI +import Foundation + +@MainActor +@Observable +class ProwlarrInstance { + private let instance: Instance + + var indexers: [Indexer] = [] + var isLoading: Bool = false + var error: API.Error? + + init(_ instance: Instance) { + if instance.type != .prowlarr { + fatalError("\(instance.type.rawValue) given to ProwlarrInstance") + } + self.instance = instance + } + + func fetchIndexers() async { + isLoading = true + error = nil + + do { + indexers = try await dependencies.api.fetchIndexers(instance) + } catch is CancellationError { + // do nothing + } catch let apiError as API.Error { + error = apiError + leaveBreadcrumb(.error, category: "prowlarr.indexers", message: "Fetch failed", data: ["error": apiError]) + } catch { + self.error = API.Error(from: error) + } + + isLoading = false + } + + func setEnabled(_ id: Int, _ enable: Bool) async -> Bool { + error = nil + + do { + _ = try await dependencies.api.setIndexersEnabled([id], enable, instance) + return true + } catch is CancellationError { + return true // optimistic UI already applied; treat cancellation as success + } catch let apiError as API.Error { + error = apiError + leaveBreadcrumb(.error, category: "prowlarr.indexers", message: "Toggle failed", data: ["error": apiError, "id": id]) + return false + } catch { + self.error = API.Error(from: error) + leaveBreadcrumb(.error, category: "prowlarr.indexers", message: "Toggle failed", data: ["error": error, "id": id]) + return false + } + } +} diff --git a/Ruddarr/Preview Content/prowlarr-indexers.json b/Ruddarr/Preview Content/prowlarr-indexers.json new file mode 100644 index 00000000..c006d544 --- /dev/null +++ b/Ruddarr/Preview Content/prowlarr-indexers.json @@ -0,0 +1,70 @@ +[ + { + "id": 1, + "name": "1337x", + "definitionName": "1337x", + "description": "1337x is a public tracker.", + "enable": true, + "protocol": "torrent", + "privacy": "public", + "priority": 25, + "language": "en-US", + "added": "2024-01-15T12:00:00Z", + "appProfileId": 1, + "tags": [], + "capabilities": { + "limitsMax": 100, + "limitsDefault": 100, + "supportsRawSearch": false, + "categories": [ + { "id": 2000, "name": "Movies", "subCategories": [] }, + { "id": 5000, "name": "TV", "subCategories": [] } + ] + } + }, + { + "id": 2, + "name": "IPTorrents", + "definitionName": "IPTorrents", + "description": "IPTorrents is a private tracker.", + "enable": false, + "protocol": "torrent", + "privacy": "private", + "priority": 10, + "language": "en-US", + "added": "2024-02-01T12:00:00Z", + "appProfileId": 1, + "tags": [1], + "capabilities": { + "limitsMax": 100, + "limitsDefault": 50, + "supportsRawSearch": true, + "categories": [ + { "id": 2000, "name": "Movies", "subCategories": [] } + ] + } + }, + { + "id": 3, + "name": "NZBgeek", + "definitionName": "Newznab", + "description": "Usenet indexer.", + "enable": true, + "protocol": "usenet", + "privacy": "private", + "priority": 5, + "language": "en-US", + "added": "2024-03-10T12:00:00Z", + "appProfileId": 1, + "tags": [], + "capabilities": { + "limitsMax": 100, + "limitsDefault": 100, + "supportsRawSearch": false, + "categories": [ + { "id": 2000, "name": "Movies", "subCategories": [] }, + { "id": 5000, "name": "TV", "subCategories": [] } + ] + } + } +] diff --git a/Ruddarr/Services/AppSettings.swift b/Ruddarr/Services/AppSettings.swift index 7cbc37e1..a44a7855 100644 --- a/Ruddarr/Services/AppSettings.swift +++ b/Ruddarr/Services/AppSettings.swift @@ -49,6 +49,10 @@ extension AppSettings { instances.filter { $0.type == .sonarr } } + var mediaInstances: [Instance] { + instances.filter { $0.type != .prowlarr } + } + var configuredInstances: [Instance] { instances.filter { !$0.id.uuidString.starts(with: "00000000") } } @@ -76,17 +80,18 @@ extension AppSettings { instances.append(instance) } - Queue.shared.instances = instances + Queue.shared.instances = mediaInstances } func deleteInstance(_ instance: Instance) { var deletedInstance = instance deletedInstance.id = UUID() - let webhook = InstanceWebhook(instance) - Task { - await webhook.delete() + if instance.type != .prowlarr { + let webhook = InstanceWebhook(instance) + await webhook.delete() + } await Spotlight(instance.id).deleteInstanceIndex() } @@ -94,7 +99,7 @@ extension AppSettings { instances.remove(at: index) } - Queue.shared.instances = instances + Queue.shared.instances = mediaInstances } } diff --git a/Ruddarr/Services/Notifications.swift b/Ruddarr/Services/Notifications.swift index 39495354..3333f6d5 100644 --- a/Ruddarr/Services/Notifications.swift +++ b/Ruddarr/Services/Notifications.swift @@ -85,7 +85,7 @@ actor Notifications { static func maybeUpdateWebhooks(_ settings: AppSettings) { Task.detached { [settings] in - let instances = await settings.instances + let instances = await settings.mediaInstances let updateNeeded = instances.map { Occurrence.hoursSince("webhookUpdated:\($0.id)") >= 6 diff --git a/Ruddarr/Utilities/View.swift b/Ruddarr/Utilities/View.swift index bb828ffa..696b96fd 100644 --- a/Ruddarr/Utilities/View.swift +++ b/Ruddarr/Utilities/View.swift @@ -84,7 +84,7 @@ private struct WithAppStateModifier: ViewModifier { .environment(RadarrInstance(radarrInstance)) .environment(SonarrInstance(sonarrInstance)) .task { - Queue.shared.instances = settings.instances + Queue.shared.instances = settings.mediaInstances setSentryContext(for: "Configuration", settings.context()) await setSentryCloudKitContext() } diff --git a/Ruddarr/Views/Activity/HistoryView.swift b/Ruddarr/Views/Activity/HistoryView.swift index c655183b..7535c1ba 100644 --- a/Ruddarr/Views/Activity/HistoryView.swift +++ b/Ruddarr/Views/Activity/HistoryView.swift @@ -41,7 +41,7 @@ struct HistoryView: View { .navigationTitle("History") .safeNavigationBarTitleDisplayMode(.inline) .task { - history.instances = settings.instances + history.instances = settings.mediaInstances await history.fetch(page, displayedEventType) } .toolbar { diff --git a/Ruddarr/Views/ActivityView.swift b/Ruddarr/Views/ActivityView.swift index 8ac93bf9..29773b86 100644 --- a/Ruddarr/Views/ActivityView.swift +++ b/Ruddarr/Views/ActivityView.swift @@ -56,7 +56,7 @@ struct ActivityView: View { .onChange(of: queue.items, updateDisplayedItems) .onChange(of: queue.items, updateSelectedItem) .onAppear { - queue.instances = settings.instances + queue.instances = settings.mediaInstances queue.performRefresh = true updateDisplayedItems() } diff --git a/Ruddarr/Views/CalendarView.swift b/Ruddarr/Views/CalendarView.swift index 6b14e23a..f7b75bac 100644 --- a/Ruddarr/Views/CalendarView.swift +++ b/Ruddarr/Views/CalendarView.swift @@ -80,9 +80,9 @@ struct CalendarView: View { todayButton } .onAppear { - if Set(calendar.instances.map(\.id)) != Set(settings.instances.map(\.id)) { + if Set(calendar.instances.map(\.id)) != Set(settings.mediaInstances.map(\.id)) { calendar.reset() - calendar.instances = settings.instances + calendar.instances = settings.mediaInstances hideCalendarView = true } } diff --git a/Ruddarr/Views/Settings/IndexerDetailView.swift b/Ruddarr/Views/Settings/IndexerDetailView.swift new file mode 100644 index 00000000..283acff0 --- /dev/null +++ b/Ruddarr/Views/Settings/IndexerDetailView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct IndexerDetailView: View { + let indexer: Indexer + let instance: Instance + + var body: some View { + Form { + Section { + LabeledContent("Name", value: indexer.name) + if let def = indexer.definitionName, def != indexer.name { + LabeledContent("Type", value: def) + } + LabeledContent("Protocol", value: indexer.protocol.label) + LabeledContent("Privacy", value: indexer.privacy.label) + LabeledContent("Priority", value: String(indexer.priority)) + if let lang = indexer.language { + LabeledContent("Language", value: lang) + } + if let added = indexer.added { + LabeledContent("Added", value: added.formatted(date: .abbreviated, time: .omitted)) + } + } + + if let desc = indexer.description, !desc.isEmpty { + Section("Description") { + Text(desc) + } + } + + if !indexer.tags.isEmpty { + Section("Tags") { + ForEach(indexer.tags, id: \.self) { tagId in + if let tag = instance.tags.first(where: { $0.id == tagId }) { + Text(tag.label) + } + } + } + } + + if let caps = indexer.capabilities, + let cats = caps.categories, + !cats.isEmpty { + Section("Categories") { + ForEach(cats) { cat in + Text(cat.name) + } + } + } + } + .formStyle(.grouped) + .textCase(nil) + .navigationTitle(indexer.name) + .safeNavigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + IndexerDetailView( + indexer: Indexer( + id: 1, + name: "1337x", + definitionName: "1337x", + description: "Public torrent tracker.", + enable: true, + protocol: .torrent, + privacy: .public, + priority: 25, + language: "en-US", + added: Date(), + appProfileId: 1, + tags: [], + capabilities: nil + ), + instance: .prowlarrDummy + ) + } +} diff --git a/Ruddarr/Views/Settings/IndexersView.swift b/Ruddarr/Views/Settings/IndexersView.swift new file mode 100644 index 00000000..cb8641a3 --- /dev/null +++ b/Ruddarr/Views/Settings/IndexersView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +struct IndexersView: View { + let instance: Instance + @State var prowlarr: ProwlarrInstance + + init(instance: Instance) { + self.instance = instance + self._prowlarr = State(wrappedValue: ProwlarrInstance(instance)) + } + + var body: some View { + List { + ForEach($prowlarr.indexers) { $indexer in + NavigationLink(value: indexer) { + IndexerRow(indexer: $indexer, prowlarr: prowlarr) + } + } + } + .navigationTitle("Indexers") + .safeNavigationBarTitleDisplayMode(.inline) + .task { await prowlarr.fetchIndexers() } + .refreshable { await refresh() } + .overlay { + if prowlarr.isLoading && prowlarr.indexers.isEmpty { + ProgressView() + } else if !prowlarr.isLoading && prowlarr.indexers.isEmpty { + if let error = prowlarr.error { + ContentUnavailableView { + Label("Indexers could not be loaded", systemImage: "exclamationmark.triangle") + } description: { + Text(error.recoverySuggestionFallback) + } actions: { + Button("Retry") { + Task { await prowlarr.fetchIndexers() } + } + } + } else { + ContentUnavailableView( + "No Indexers", + systemImage: "magnifyingglass", + description: Text("Add indexers in the Prowlarr web interface.") + ) + } + } + } + .navigationDestination(for: Indexer.self) { indexer in + IndexerDetailView(indexer: indexer, instance: instance) + } + } + + func refresh() async { + await prowlarr.fetchIndexers() + if let error = prowlarr.error, !prowlarr.indexers.isEmpty { + dependencies.toast.show(.error(error.recoverySuggestionFallback)) + } + } +} + +struct IndexerRow: View { + @Binding var indexer: Indexer + let prowlarr: ProwlarrInstance + + @State private var isCommitting = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(indexer.name) + HStack(spacing: 6) { + Text(indexer.protocol.label) + Text(verbatim: "•") + Text(indexer.privacy.label) + } + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle(isOn: Binding( + get: { indexer.enable }, + set: { newValue in + indexer.enable = newValue + Task { await commitEnabled(newValue) } + } + )) { } + .labelsHidden() + .disabled(isCommitting) + } + } + + func commitEnabled(_ newValue: Bool) async { + isCommitting = true + defer { isCommitting = false } + + guard await prowlarr.setEnabled(indexer.id, newValue) else { + indexer.enable = !newValue + dependencies.toast.show(.error( + prowlarr.error?.recoverySuggestionFallback ?? String(localized: "Try again later."))) + return + } + dependencies.toast.show(newValue ? .indexerEnabled : .indexerDisabled) + } +} diff --git a/Ruddarr/Views/Settings/InstanceEditView+Functions.swift b/Ruddarr/Views/Settings/InstanceEditView+Functions.swift index f8e04e03..d8e46ea7 100644 --- a/Ruddarr/Views/Settings/InstanceEditView+Functions.swift +++ b/Ruddarr/Views/Settings/InstanceEditView+Functions.swift @@ -129,6 +129,10 @@ extension InstanceEditView { if [":8989", "sonar"].contains(where: instance.url.contains) { instance.type = .sonarr } + + if [":9696", "prowlar"].contains(where: instance.url.contains) { + instance.type = .prowlarr + } } func pasteHeader() { diff --git a/Ruddarr/Views/Settings/InstanceEditView.swift b/Ruddarr/Views/Settings/InstanceEditView.swift index aabb3439..c842bbf2 100644 --- a/Ruddarr/Views/Settings/InstanceEditView.swift +++ b/Ruddarr/Views/Settings/InstanceEditView.swift @@ -264,6 +264,7 @@ struct InstanceEditView: View { switch instance.type { case .radarr: "http://10.0.1.1:7878" case .sonarr: "http://10.0.1.1:8989" + case .prowlarr: "http://10.0.1.1:9696" } } diff --git a/Ruddarr/Views/Settings/InstanceRow.swift b/Ruddarr/Views/Settings/InstanceRow.swift index c0640b58..151e9cf2 100644 --- a/Ruddarr/Views/Settings/InstanceRow.swift +++ b/Ruddarr/Views/Settings/InstanceRow.swift @@ -67,28 +67,45 @@ struct InstanceRow: View { connection = .pending - async let systemStatus = try dependencies.api.systemStatus(instance) - async let rootFolders = try dependencies.api.rootFolders(instance) - async let qualityProfiles = try dependencies.api.qualityProfiles(instance) - async let tags = dependencies.api.getTags(instance) + if instance.type == .prowlarr { + async let systemStatus = try dependencies.api.systemStatus(instance) + async let tags = dependencies.api.getTags(instance) - let data = try await systemStatus + let data = try await systemStatus - instance.name = data.instanceName - instance.version = data.version - instance.rootFolders = try await rootFolders - instance.qualityProfiles = try await qualityProfiles - instance.tags = try await tags + instance.name = data.instanceName + instance.version = data.version + instance.tags = try await tags - settings.saveInstance(instance) + settings.saveInstance(instance) - Occurrence.occurred(lastCheck) + Occurrence.occurred(lastCheck) - let webhook = InstanceWebhook(instance) - await webhook.synchronize() - self.webhook = webhook.isEnabled ? .enabled : .disabled + connection = .reachable + } else { + async let systemStatus = try dependencies.api.systemStatus(instance) + async let rootFolders = try dependencies.api.rootFolders(instance) + async let qualityProfiles = try dependencies.api.qualityProfiles(instance) + async let tags = dependencies.api.getTags(instance) + + let data = try await systemStatus + + instance.name = data.instanceName + instance.version = data.version + instance.rootFolders = try await rootFolders + instance.qualityProfiles = try await qualityProfiles + instance.tags = try await tags + + settings.saveInstance(instance) - connection = .reachable + Occurrence.occurred(lastCheck) + + let webhook = InstanceWebhook(instance) + await webhook.synchronize() + self.webhook = webhook.isEnabled ? .enabled : .disabled + + connection = .reachable + } } catch is CancellationError { // do nothing } catch { diff --git a/Ruddarr/Views/Settings/InstanceView+Notifications.swift b/Ruddarr/Views/Settings/InstanceView+Notifications.swift index c0c256c7..56a0f256 100644 --- a/Ruddarr/Views/Settings/InstanceView+Notifications.swift +++ b/Ruddarr/Views/Settings/InstanceView+Notifications.swift @@ -281,6 +281,8 @@ extension InstanceView { } func initialWebhookSync() async { + guard instance.type != .prowlarr else { return } + if notificationsAllowed && cloudKitEnabled && entitledToService { await webhook.synchronize() diff --git a/Ruddarr/Views/Settings/InstanceView.swift b/Ruddarr/Views/Settings/InstanceView.swift index a79061fe..82ea37bd 100644 --- a/Ruddarr/Views/Settings/InstanceView.swift +++ b/Ruddarr/Views/Settings/InstanceView.swift @@ -34,7 +34,17 @@ struct InstanceView: View { instanceHeaders } - notifications + if instance.type == .prowlarr { + Section { + NavigationLink(value: SettingsView.Path.indexers(instance.id)) { + Label("Indexers", systemImage: "magnifyingglass") + } + } + } + + if instance.type != .prowlarr { + notifications + } #if DEBUG Button { diff --git a/Ruddarr/Views/SettingsView.swift b/Ruddarr/Views/SettingsView.swift index 6c67a8c6..4cd0e189 100644 --- a/Ruddarr/Views/SettingsView.swift +++ b/Ruddarr/Views/SettingsView.swift @@ -13,9 +13,11 @@ struct SettingsView: View { case createInstance case viewInstance(Instance.ID) case editInstance(Instance.ID) + case indexers(Instance.ID) } var body: some View { + // swiftlint:disable:next closure_body_length NavigationStack(path: dependencies.$router.settingsPath) { Form { instanceSection @@ -55,6 +57,11 @@ struct SettingsView: View { .environment(sonarrInstance) .environmentObject(settings) } + case .indexers(let instanceId): + if let instance = settings.instanceById(instanceId), instance.type == .prowlarr { + IndexersView(instance: instance) + .environmentObject(settings) + } } } } @@ -135,10 +142,11 @@ struct SettingsView: View { func checkInstance() async { let status = await Notifications.authorizationStatus() - let uniqueNames = Set(settings.instances.map { $0.name }) + let mediaInstances = settings.mediaInstances + let uniqueNames = Set(mediaInstances.map { $0.name }) if status == .authorized { - showInstanceNameWarning = settings.instances.count != uniqueNames.count + showInstanceNameWarning = mediaInstances.count != uniqueNames.count } let hasLocalInstances = settings.instances.contains { $0.isPrivateIp() } From 47873a22d5077df1e920e8d3ddfc922d4f3e5259 Mon Sep 17 00:00:00 2001 From: Sam Porter <29922188+samuelporter@users.noreply.github.com> Date: Sat, 23 May 2026 21:35:51 -0500 Subject: [PATCH 2/2] Add Prowlarr search and grab --- Ruddarr.xcodeproj/project.pbxproj | 32 +++ Ruddarr/Dependencies/API/API+Live.swift | 35 +++ Ruddarr/Dependencies/API/API+Mock.swift | 4 + Ruddarr/Dependencies/API/API.swift | 2 + Ruddarr/Localizable.xcstrings | 158 +++++++++++- .../Models/Instances/ProwlarrRelease.swift | 66 +++++ Ruddarr/Models/Instances/ProwlarrSearch.swift | 93 +++++++ .../Instances/ProwlarrSearchCategory.swift | 45 ++++ .../Models/Instances/ProwlarrSearchSort.swift | 127 ++++++++++ Ruddarr/Preview Content/prowlarr-search.json | 60 +++++ Ruddarr/Views/Settings/InstanceView.swift | 8 +- .../Views/Settings/ProwlarrSearchRow.swift | 72 ++++++ .../Views/Settings/ProwlarrSearchSheet.swift | 184 ++++++++++++++ .../Views/Settings/ProwlarrSearchView.swift | 236 ++++++++++++++++++ Ruddarr/Views/SettingsView.swift | 6 + 15 files changed, 1123 insertions(+), 5 deletions(-) create mode 100644 Ruddarr/Models/Instances/ProwlarrRelease.swift create mode 100644 Ruddarr/Models/Instances/ProwlarrSearch.swift create mode 100644 Ruddarr/Models/Instances/ProwlarrSearchCategory.swift create mode 100644 Ruddarr/Models/Instances/ProwlarrSearchSort.swift create mode 100644 Ruddarr/Preview Content/prowlarr-search.json create mode 100644 Ruddarr/Views/Settings/ProwlarrSearchRow.swift create mode 100644 Ruddarr/Views/Settings/ProwlarrSearchSheet.swift create mode 100644 Ruddarr/Views/Settings/ProwlarrSearchView.swift diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 237ca387..bd15a2f1 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -12,6 +12,14 @@ 22607C6F2FB96F27003C5955 /* ProwlarrInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C6E2FB96F27003C5955 /* ProwlarrInstance.swift */; }; 22607C712FB97253003C5955 /* IndexersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C702FB97253003C5955 /* IndexersView.swift */; }; 22607C732FB972BE003C5955 /* IndexerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C722FB972BE003C5955 /* IndexerDetailView.swift */; }; + 22607C752FC2523B003C5955 /* prowlarr-search.json in Resources */ = {isa = PBXBuildFile; fileRef = 22607C742FC2523B003C5955 /* prowlarr-search.json */; }; + 22607C772FC252A8003C5955 /* ProwlarrRelease.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C762FC252A8003C5955 /* ProwlarrRelease.swift */; }; + 22607C792FC25455003C5955 /* ProwlarrSearchCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C782FC25455003C5955 /* ProwlarrSearchCategory.swift */; }; + 22607C7B2FC25717003C5955 /* ProwlarrSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C7A2FC25717003C5955 /* ProwlarrSearch.swift */; }; + 22607C7D2FC257EB003C5955 /* ProwlarrSearchRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C7C2FC257EB003C5955 /* ProwlarrSearchRow.swift */; }; + 22607C7F2FC25870003C5955 /* ProwlarrSearchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C7E2FC25870003C5955 /* ProwlarrSearchSheet.swift */; }; + 22607C812FC258F3003C5955 /* ProwlarrSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C802FC258F3003C5955 /* ProwlarrSearchView.swift */; }; + 22607C832FC26319003C5955 /* ProwlarrSearchSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22607C822FC26319003C5955 /* ProwlarrSearchSort.swift */; }; 2B949CE52CC92CA20088B1A8 /* sonarr-history.json in Resources */ = {isa = PBXBuildFile; fileRef = 2B949CE42CC92C970088B1A8 /* sonarr-history.json */; }; 2B949CE72CC92F370088B1A8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B949CE62CC92F320088B1A8 /* History.swift */; }; 2B949CEB2CCBC8690088B1A8 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B949CEA2CCBC8600088B1A8 /* HistoryView.swift */; }; @@ -271,6 +279,14 @@ 22607C6E2FB96F27003C5955 /* ProwlarrInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrInstance.swift; sourceTree = ""; }; 22607C702FB97253003C5955 /* IndexersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexersView.swift; sourceTree = ""; }; 22607C722FB972BE003C5955 /* IndexerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexerDetailView.swift; sourceTree = ""; }; + 22607C742FC2523B003C5955 /* prowlarr-search.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "prowlarr-search.json"; sourceTree = ""; }; + 22607C762FC252A8003C5955 /* ProwlarrRelease.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrRelease.swift; sourceTree = ""; }; + 22607C782FC25455003C5955 /* ProwlarrSearchCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrSearchCategory.swift; sourceTree = ""; }; + 22607C7A2FC25717003C5955 /* ProwlarrSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrSearch.swift; sourceTree = ""; }; + 22607C7C2FC257EB003C5955 /* ProwlarrSearchRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrSearchRow.swift; sourceTree = ""; }; + 22607C7E2FC25870003C5955 /* ProwlarrSearchSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrSearchSheet.swift; sourceTree = ""; }; + 22607C802FC258F3003C5955 /* ProwlarrSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrSearchView.swift; sourceTree = ""; }; + 22607C822FC26319003C5955 /* ProwlarrSearchSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProwlarrSearchSort.swift; sourceTree = ""; }; 2B949CE42CC92C970088B1A8 /* sonarr-history.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "sonarr-history.json"; sourceTree = ""; }; 2B949CE62CC92F320088B1A8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; 2B949CEA2CCBC8600088B1A8 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; @@ -592,6 +608,9 @@ BBFFB50F2D72717000AB5EFD /* BugSheet.swift */, 22607C702FB97253003C5955 /* IndexersView.swift */, 22607C722FB972BE003C5955 /* IndexerDetailView.swift */, + 22607C7C2FC257EB003C5955 /* ProwlarrSearchRow.swift */, + 22607C7E2FC25870003C5955 /* ProwlarrSearchSheet.swift */, + 22607C802FC258F3003C5955 /* ProwlarrSearchView.swift */, ); path = Settings; sourceTree = ""; @@ -925,6 +944,7 @@ BBA06B582F47D4B600A5F9B4 /* popular-movies.json */, BB181D6E2F48E8DF00981037 /* popular-series.json */, 22607C6C2FB96E58003C5955 /* prowlarr-indexers.json */, + 22607C742FC2523B003C5955 /* prowlarr-search.json */, ); path = "Preview Content"; sourceTree = ""; @@ -985,6 +1005,10 @@ BBE7F8A82B6424350014DD57 /* RadarrInstance.swift */, BB507D1E2BD96EEB00EC4016 /* SonarrInstance.swift */, 22607C6E2FB96F27003C5955 /* ProwlarrInstance.swift */, + 22607C762FC252A8003C5955 /* ProwlarrRelease.swift */, + 22607C7A2FC25717003C5955 /* ProwlarrSearch.swift */, + 22607C782FC25455003C5955 /* ProwlarrSearchCategory.swift */, + 22607C822FC26319003C5955 /* ProwlarrSearchSort.swift */, 22607C682FB96A32003C5955 /* Indexer.swift */, ); path = Instances; @@ -1191,6 +1215,7 @@ BB54D0282E90932400F5CF42 /* AppIconPlex.icon in Resources */, BB54D0292E90932400F5CF42 /* AppIconPodcasts.icon in Resources */, BB181D6F2F48E8DF00981037 /* popular-series.json in Resources */, + 22607C752FC2523B003C5955 /* prowlarr-search.json in Resources */, BB54D02A2E90932400F5CF42 /* AppIconWarp.icon in Resources */, BB77C2CE2C1A019B00125852 /* AppShortcuts.xcstrings in Resources */, BBD5D8102C013E30008E3B3F /* movie-queue.json in Resources */, @@ -1245,6 +1270,7 @@ BB05C9052B86D0EC009B6444 /* Languages.swift in Sources */, BBC136A42B62DD780074C7AA /* Network.swift in Sources */, 22607C6F2FB96F27003C5955 /* ProwlarrInstance.swift in Sources */, + 22607C792FC25455003C5955 /* ProwlarrSearchCategory.swift in Sources */, BB89ABC62B756B91009FB62D /* MovieReleaseRow.swift in Sources */, BB77C2CC2C19F41800125852 /* SeriesDefaults.swift in Sources */, BB7DDF652B718DFB0001CDFC /* MovieReleaseSheet.swift in Sources */, @@ -1258,12 +1284,14 @@ BBF706BC2B6220BF00B2B504 /* Sentry.swift in Sources */, BB0C3EC72BF90C5900632CB1 /* NoInstance.swift in Sources */, BB3B4BF92CE13437001A8896 /* LabeledGroupBox.swift in Sources */, + 22607C7B2FC25717003C5955 /* ProwlarrSearch.swift in Sources */, BBC94DE92B5F63A000504568 /* Telemetry.swift in Sources */, BBBCA05C2BE958F300BAE374 /* Toolbar.swift in Sources */, BB9DC2252BA920AF00459FFF /* API+Error.swift in Sources */, BB035A442C1D3B71005DFD45 /* Spotlight.swift in Sources */, BB4B34F32B7BFE640063F2D3 /* AppDelegate.swift in Sources */, BB9DC22B2BAA11C700459FFF /* MoviesView+Overlay.swift in Sources */, + 22607C812FC258F3003C5955 /* ProwlarrSearchView.swift in Sources */, BBF583B42BABD75300AFA7FB /* Episode.swift in Sources */, BB507D222BD9705E00EC4016 /* SeriesModel.swift in Sources */, BB4E441E2B842C0A00E0BC73 /* MovieContextMenu.swift in Sources */, @@ -1297,6 +1325,7 @@ BB6F23AD2B6ABBBD00A4347A /* SettingsSystemSection.swift in Sources */, BB0FE1112BED30D100D1D847 /* SeriesFiles.swift in Sources */, BB8A56792C0004D500199DB7 /* Reviews.swift in Sources */, + 22607C772FC252A8003C5955 /* ProwlarrRelease.swift in Sources */, BBD5D8122C014538008E3B3F /* Queue.swift in Sources */, BBC309D82BED584C004080FD /* Platform.swift in Sources */, BB96C32B2BE833AD00E24C1C /* SeriesForm.swift in Sources */, @@ -1341,9 +1370,11 @@ BBA733632BB4C1F400A5022B /* MovieMetadataView.swift in Sources */, BBBB08D42B77FBC700BADBA1 /* IconsView.swift in Sources */, BBF583BD2BACA3B200AFA7FB /* Series.swift in Sources */, + 22607C7D2FC257EB003C5955 /* ProwlarrSearchRow.swift in Sources */, BB2250F22BEE87D7009EAA6B /* ContentView.swift in Sources */, BB0BEA8D2BE593C2004DBFE6 /* SeriesEpisodes.swift in Sources */, BB05C9092B86E272009B6444 /* MovieDetails+Overview.swift in Sources */, + 22607C7F2FC25870003C5955 /* ProwlarrSearchSheet.swift in Sources */, BBB574B02F4CEFD1009B1DD0 /* Links.swift in Sources */, BB89058B2E41359E0057C62B /* SeasonCard.swift in Sources */, BB2370E22DCE76F500261710 /* QueueItem.swift in Sources */, @@ -1366,6 +1397,7 @@ BBC77E242B7AAD3300573EBD /* InstanceView.swift in Sources */, BBC309DA2BED7FE4004080FD /* Environment.swift in Sources */, BBD3F7D22BE9A6320050D9A0 /* SeriesReleasesView.swift in Sources */, + 22607C832FC26319003C5955 /* ProwlarrSearchSort.swift in Sources */, BB63E1F02C17DE1A00BCCA94 /* Intents.swift in Sources */, BBF583B92BAC99CA00AFA7FB /* CalendarDate.swift in Sources */, BB25DC6A2B63161E00FE55A0 /* MovieForm.swift in Sources */, diff --git a/Ruddarr/Dependencies/API/API+Live.swift b/Ruddarr/Dependencies/API/API+Live.swift index d117722d..82064158 100644 --- a/Ruddarr/Dependencies/API/API+Live.swift +++ b/Ruddarr/Dependencies/API/API+Live.swift @@ -327,6 +327,41 @@ extension API { let body = IndexerBulkResource(ids: ids, enable: enable) return try await request(method: .put, url: url, headers: instance.auth, body: body) + }, searchProwlarr: { query, categories, instance in + var queryItems: [URLQueryItem] = [ + .init(name: "query", value: query), + .init(name: "type", value: "search"), + .init(name: "limit", value: "100"), + ] + + if !categories.isEmpty { + queryItems.append(.init( + name: "categories", + value: categories.map(String.init).joined(separator: ",") + )) + } + + let url = try instance.baseURL() + .appending(path: "/api/v1/search") + .appending(queryItems: queryItems) + + return try await request(url: url, headers: instance.auth, timeout: instance.timeout(.releaseSearch)) + }, grabProwlarrRelease: { guid, indexerId, instance in + let url = try instance.baseURL() + .appending(path: "/api/v1/search") + + struct GrabBody: Encodable { + let guid: String + let indexerId: Int + } + + return try await request( + method: .post, + url: url, + headers: instance.auth, + body: GrabBody(guid: guid, indexerId: indexerId), + timeout: instance.timeout(.releaseDownload) + ) }) } } diff --git a/Ruddarr/Dependencies/API/API+Mock.swift b/Ruddarr/Dependencies/API/API+Mock.swift index 09d56ea1..dcfa6c31 100644 --- a/Ruddarr/Dependencies/API/API+Mock.swift +++ b/Ruddarr/Dependencies/API/API+Mock.swift @@ -202,6 +202,10 @@ extension API { loadPreviewData(filename: "prowlarr-indexers") }, setIndexersEnabled: { _, _, _ in Empty() + }, searchProwlarr: { _, _, _ in + loadPreviewData(filename: "prowlarr-search") + }, grabProwlarrRelease: { _, _, _ in + Empty() }) } } diff --git a/Ruddarr/Dependencies/API/API.swift b/Ruddarr/Dependencies/API/API.swift index c0a9e02d..c2a228ae 100644 --- a/Ruddarr/Dependencies/API/API.swift +++ b/Ruddarr/Dependencies/API/API.swift @@ -57,6 +57,8 @@ struct API { var fetchIndexers: (Instance) async throws -> [Indexer] var setIndexersEnabled: (_ ids: [Int], _ enable: Bool, Instance) async throws -> Empty + var searchProwlarr: (_ query: String, _ categories: [Int], Instance) async throws -> [ProwlarrRelease] + var grabProwlarrRelease: (_ guid: String, _ indexerId: Int, Instance) async throws -> Empty } extension API { diff --git a/Ruddarr/Localizable.xcstrings b/Ruddarr/Localizable.xcstrings index d3b14286..2edf94e7 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -578,6 +578,17 @@ } } }, + "Any Category" : { + "comment" : "Prowlarr category picker", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Any Category" + } + } + } + }, "Any Client" : { "localizations" : { "en" : { @@ -772,6 +783,7 @@ } }, "Audio" : { + "comment" : "Prowlarr category picker", "localizations" : { "en" : { "stringUnit" : { @@ -781,6 +793,17 @@ } } }, + "Audiobooks" : { + "comment" : "Prowlarr category picker", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audiobooks" + } + } + } + }, "Authentication" : { "localizations" : { "en" : { @@ -896,7 +919,7 @@ } }, "Books" : { - "comment" : "Localized name of Apple's Books app", + "comment" : "Localized name of Apple's Books app\nProwlarr category picker", "localizations" : { "en" : { "stringUnit" : { @@ -958,6 +981,16 @@ } } }, + "Category" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Category" + } + } + } + }, "Channels" : { "comment" : "Audio channel count", "localizations" : { @@ -1101,6 +1134,17 @@ } } }, + "Console" : { + "comment" : "Prowlarr category picker", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Console" + } + } + } + }, "Continue" : { "comment" : "Button to close whats new sheet", "localizations" : { @@ -1910,6 +1954,18 @@ } } }, + "Grab" : { + "comment" : "Short version of Grab Release", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grab" + } + } + } + }, "Grab Release" : { "localizations" : { "en" : { @@ -1931,6 +1987,28 @@ } } }, + "Grabs" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grabs" + } + } + } + }, + "Grabs %lld" : { + "comment" : "Prowlarr usenet result — grab count", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grabs %lld" + } + } + } + }, "Grid" : { "localizations" : { "en" : { @@ -2574,6 +2652,17 @@ } } }, + "Manage" : { + "comment" : "Prowlarr indexers section — manage row label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage" + } + } + } + }, "Manual Import" : { "localizations" : { "en" : { @@ -2916,7 +3005,7 @@ } }, "Movies" : { - "comment" : "Plural. Tab/sidebar menu item", + "comment" : "Plural. Tab/sidebar menu item\nProwlarr category picker", "localizations" : { "en" : { "stringUnit" : { @@ -3850,7 +3939,7 @@ } }, "Other" : { - "comment" : "Type of the extra movie file", + "comment" : "Prowlarr category picker\nType of the extra movie file", "localizations" : { "en" : { "stringUnit" : { @@ -4307,6 +4396,17 @@ } } }, + "Relevance" : { + "comment" : "Prowlarr search sort option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relevance" + } + } + } + }, "Relevant" : { "comment" : "Media search scope", "localizations" : { @@ -4508,6 +4608,16 @@ } } }, + "Search across your enabled Prowlarr indexers. Grabs are sent to Prowlarr's download client and won't appear in Ruddarr's Activity." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search across your enabled Prowlarr indexers. Grabs are sent to Prowlarr's download client and won't appear in Ruddarr's Activity." + } + } + } + }, "Search for Movie" : { "localizations" : { "en" : { @@ -4558,6 +4668,16 @@ } } }, + "Search Indexers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search Indexers" + } + } + } + }, "Search Monitored" : { "localizations" : { "en" : { @@ -4568,6 +4688,16 @@ } } }, + "Search Prowlarr" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search Prowlarr" + } + } + } + }, "Searching..." : { "localizations" : { "en" : { @@ -4853,6 +4983,17 @@ } } }, + "Software" : { + "comment" : "Prowlarr category picker — Newznab PC category", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Software" + } + } + } + }, "Some releases are hidden by the selected filters." : { "localizations" : { "en" : { @@ -5277,6 +5418,17 @@ } } }, + "TV" : { + "comment" : "Prowlarr category picker", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TV" + } + } + } + }, "Type" : { "comment" : "Short version of Series Type", "localizations" : { diff --git a/Ruddarr/Models/Instances/ProwlarrRelease.swift b/Ruddarr/Models/Instances/ProwlarrRelease.swift new file mode 100644 index 00000000..514275bb --- /dev/null +++ b/Ruddarr/Models/Instances/ProwlarrRelease.swift @@ -0,0 +1,66 @@ +import Foundation + +struct ProwlarrRelease: Identifiable, Codable { + var id: String { guid } + + let guid: String + let title: String + let indexerId: Int + let indexer: String? + + let size: Int + let age: Int + let ageMinutes: Float + let publishDate: Date? + + let network: ReleaseProtocol + let seeders: Int? + let leechers: Int? + let grabs: Int? + + let categories: [ProwlarrCategoryRef] + let infoUrl: String? + + enum CodingKeys: String, CodingKey { + case guid + case title + case indexerId + case indexer + case size + case age + case ageMinutes + case publishDate + case network = "protocol" + case seeders + case leechers + case grabs + case categories + case infoUrl + } + + var isTorrent: Bool { network == .torrent } + var isUsenet: Bool { network == .usenet } + + var indexerLabel: String { + guard let name = indexer else { + return String(indexerId) + } + + return formatIndexer(name) + } + var sizeLabel: String { formatBytes(size) } + var ageLabel: String { formatAge(ageMinutes) } + + var typeLabel: String { + if network == .torrent { + return "\(network.label) (\(seeders ?? 0)/\(leechers ?? 0))" + } + + return network.label + } +} + +struct ProwlarrCategoryRef: Codable, Hashable, Identifiable { + let id: Int + let name: String? +} diff --git a/Ruddarr/Models/Instances/ProwlarrSearch.swift b/Ruddarr/Models/Instances/ProwlarrSearch.swift new file mode 100644 index 00000000..df4bee5b --- /dev/null +++ b/Ruddarr/Models/Instances/ProwlarrSearch.swift @@ -0,0 +1,93 @@ +import os +import SwiftUI + +@MainActor +@Observable +class ProwlarrSearch { + private let instance: Instance + + var query: String = "" + var category: ProwlarrSearchCategory = .all + + var items: [ProwlarrRelease] = [] + var isSearching: Bool = false + var hasSearched: Bool = false + var error: API.Error? + var errorBinding: Binding { .init(get: { self.error != nil }, set: { _ in }) } + + var protocols: [String] = [] + var indexers: [String] = [] + + func reset() { + items = [] + hasSearched = false + error = nil + setFilterData() + } + + func setFilterData() { + var seenProtocols: Set = [] + protocols = items.map { $0.network.label }.filter { seenProtocols.insert($0).inserted } + + var seenIndexers: Set = [] + indexers = items + .map { $0.indexerLabel } + .filter { seenIndexers.insert($0).inserted } + .sorted() + } + + init(_ instance: Instance) { + if instance.type != .prowlarr { + fatalError("\(instance.type.rawValue) given to ProwlarrSearch") + } + self.instance = instance + } + + func search() async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + items = [] + error = nil + isSearching = true + setFilterData() + + do { + let results = try await dependencies.api.searchProwlarr(trimmed, category.categoryIds, instance) + try Task.checkCancellation() + items = results + setFilterData() + hasSearched = true + } catch is CancellationError { + // do nothing + } catch let apiError as API.Error { + error = apiError + hasSearched = true + leaveBreadcrumb(.error, category: "prowlarr.search", message: "Search failed", data: ["error": apiError]) + } catch { + self.error = API.Error(from: error) + hasSearched = true + } + + isSearching = false + } + + func grab(_ release: ProwlarrRelease) async -> Bool { + error = nil + + do { + _ = try await dependencies.api.grabProwlarrRelease(release.guid, release.indexerId, instance) + return true + } catch is CancellationError { + return false + } catch let apiError as API.Error { + error = apiError + leaveBreadcrumb(.error, category: "prowlarr.search", message: "Grab failed", data: ["error": apiError, "guid": release.guid]) + return false + } catch { + self.error = API.Error(from: error) + leaveBreadcrumb(.error, category: "prowlarr.search", message: "Grab failed", data: ["error": error, "guid": release.guid]) + return false + } + } +} diff --git a/Ruddarr/Models/Instances/ProwlarrSearchCategory.swift b/Ruddarr/Models/Instances/ProwlarrSearchCategory.swift new file mode 100644 index 00000000..e7e2d55f --- /dev/null +++ b/Ruddarr/Models/Instances/ProwlarrSearchCategory.swift @@ -0,0 +1,45 @@ +import Foundation + +enum ProwlarrSearchCategory: String, CaseIterable, Identifiable { + case all + case movies + case tv + case audio + case audiobooks + case books + case pc + case console + case other + + var id: Self { self } + + var label: String { + switch self { + case .all: String(localized: "Any Category", comment: "Prowlarr category picker") + case .movies: String(localized: "Movies", comment: "Prowlarr category picker") + case .tv: String(localized: "TV", comment: "Prowlarr category picker") + case .audio: String(localized: "Audio", comment: "Prowlarr category picker") + case .audiobooks: String(localized: "Audiobooks", comment: "Prowlarr category picker") + case .books: String(localized: "Books", comment: "Prowlarr category picker") + case .pc: String(localized: "Software", comment: "Prowlarr category picker — Newznab PC category") + case .console: String(localized: "Console", comment: "Prowlarr category picker") + case .other: String(localized: "Other", comment: "Prowlarr category picker") + } + } + + // Newznab parent IDs (1000, 2000, …) include all their subcategories on most indexers, + // so .audio (3000) already returns audiobooks. .audiobooks (3030) is the narrower subset. + var categoryIds: [Int] { + switch self { + case .all: [] + case .movies: [2_000] + case .tv: [5_000] + case .audio: [3_000] + case .audiobooks: [3_030] + case .books: [7_000] + case .pc: [4_000] + case .console: [1_000] + case .other: [8_000] + } + } +} diff --git a/Ruddarr/Models/Instances/ProwlarrSearchSort.swift b/Ruddarr/Models/Instances/ProwlarrSearchSort.swift new file mode 100644 index 00000000..5f30c246 --- /dev/null +++ b/Ruddarr/Models/Instances/ProwlarrSearchSort.swift @@ -0,0 +1,127 @@ +import SwiftUI + +struct ProwlarrSearchSort: Equatable { + var isAscending: Bool = false + var option: Option = .byRelevance + + var network: String = .all + var indexer: String = .all + + static func == (lhs: ProwlarrSearchSort, rhs: ProwlarrSearchSort) -> Bool { + lhs.isAscending == rhs.isAscending && + lhs.option == rhs.option && + lhs.network == rhs.network && + lhs.indexer == rhs.indexer + } + + enum Option: Codable, Hashable, Identifiable, CaseIterable { + var id: Self { self } + + case byRelevance + case bySeeders + case byFilesize + case byAge + case byGrabs + + var label: some View { + switch self { + case .byRelevance: Label(String(localized: "Relevance", comment: "Prowlarr search sort option"), systemImage: "sparkle") + case .bySeeders: Label(String(localized: "Seeders", comment: "Release filter"), systemImage: "person.wave.2") + case .byFilesize: Label(String(localized: "File Size", comment: "Release filter"), systemImage: "internaldrive") + case .byAge: Label(String(localized: "Age", comment: "Release filter"), systemImage: "calendar") + case .byGrabs: Label(String(localized: "Grabs"), systemImage: "arrow.down.circle") + } + } + + func isOrderedBefore(_ lhs: ProwlarrRelease, _ rhs: ProwlarrRelease) -> Bool { + switch self { + case .byRelevance: + preconditionFailure("byRelevance is handled by the early-return in filterAndSortItems") + case .bySeeders: + lhs.seeders ?? 0 > rhs.seeders ?? 0 + case .byFilesize: + lhs.size > rhs.size + case .byAge: + lhs.ageMinutes > rhs.ageMinutes + case .byGrabs: + lhs.grabs ?? 0 > rhs.grabs ?? 0 + } + } + } + + var hasFilter: Bool { + network != .all || indexer != .all + } + + mutating func resetFilters() { + network = .all + indexer = .all + } + + func filterAndSortItems(_ items: [ProwlarrRelease]) -> [ProwlarrRelease] { + let filtered = items.filter { release in + [release.network.label, .all].contains(network) && + [release.indexerLabel, .all].contains(indexer) + } + + if option == .byRelevance { + return filtered + } + + let comparator = option.isOrderedBefore + + return filtered.sorted { + isAscending ? comparator($1, $0) : comparator($0, $1) + } + } +} + +extension ProwlarrSearchSort: RawRepresentable { + init?(rawValue: String) { + do { + guard let data = rawValue.data(using: .utf8) else { return nil } + self = try JSONDecoder().decode(ProwlarrSearchSort.self, from: data) + } catch { + leaveBreadcrumb(.fatal, category: "prowlarr.search.sort", message: "JSON decode failed: \(error)", data: ["error": error]) + self = .init() + } + } + + var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { + return "{}" + } + + return result + } +} + +extension ProwlarrSearchSort: Codable { + enum CodingKeys: String, CodingKey { + case isAscending + case option + case network + case indexer + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + try self.init( + isAscending: container.decode(Bool.self, forKey: .isAscending), + option: container.decode(Option.self, forKey: .option), + network: container.decode(String.self, forKey: .network), + indexer: container.decode(String.self, forKey: .indexer) + ) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(isAscending, forKey: .isAscending) + try container.encode(option, forKey: .option) + try container.encode(network, forKey: .network) + try container.encode(indexer, forKey: .indexer) + } +} diff --git a/Ruddarr/Preview Content/prowlarr-search.json b/Ruddarr/Preview Content/prowlarr-search.json new file mode 100644 index 00000000..4f005314 --- /dev/null +++ b/Ruddarr/Preview Content/prowlarr-search.json @@ -0,0 +1,60 @@ +[ + { + "guid": "https://1337x.example/torrent/abc123", + "title": "Brandon Sanderson - The Way of Kings (Stormlight #1) [Audiobook MP3 96kbps]", + "indexerId": 1, + "indexer": "1337x", + "size": 1503238553, + "age": 3, + "ageMinutes": 4320.0, + "publishDate": "2026-05-20T12:00:00Z", + "protocol": "torrent", + "seeders": 18, + "leechers": 2, + "grabs": 47, + "categories": [ + { "id": 3000, "name": "Audio" }, + { "id": 3030, "name": "Audio/Audiobook" } + ], + "infoUrl": "https://1337x.example/torrent/abc123", + "downloadUrl": "https://1337x.example/torrent/abc123.torrent" + }, + { + "guid": "https://audiobookbay.example/torrent/def456", + "title": "Brandon Sanderson - Words of Radiance (Stormlight #2) [Audiobook M4B]", + "indexerId": 2, + "indexer": "AudioBookBay", + "size": 2254857830, + "age": 11, + "ageMinutes": 15840.0, + "publishDate": "2026-05-12T08:30:00Z", + "protocol": "torrent", + "seeders": 42, + "leechers": 5, + "grabs": 312, + "categories": [ + { "id": 3030, "name": "Audio/Audiobook" } + ], + "infoUrl": "https://audiobookbay.example/torrent/def456", + "downloadUrl": "https://audiobookbay.example/torrent/def456.torrent" + }, + { + "guid": "https://nzbgeek.example/release/ghi789", + "title": "Stormlight Archive Books 1-4 [Audiobook Collection FLAC]", + "indexerId": 3, + "indexer": "NZBgeek", + "size": 15891894272, + "age": 42, + "ageMinutes": 60480.0, + "publishDate": "2026-04-11T03:00:00Z", + "protocol": "usenet", + "seeders": null, + "leechers": null, + "grabs": 87, + "categories": [ + { "id": 3030, "name": "Audio/Audiobook" } + ], + "infoUrl": "https://nzbgeek.example/release/ghi789", + "downloadUrl": "https://nzbgeek.example/release/ghi789.nzb" + } +] diff --git a/Ruddarr/Views/Settings/InstanceView.swift b/Ruddarr/Views/Settings/InstanceView.swift index 82ea37bd..4333ae0f 100644 --- a/Ruddarr/Views/Settings/InstanceView.swift +++ b/Ruddarr/Views/Settings/InstanceView.swift @@ -35,9 +35,13 @@ struct InstanceView: View { } if instance.type == .prowlarr { - Section { + Section("Indexers") { + NavigationLink(value: SettingsView.Path.prowlarrSearch(instance.id)) { + Label("Search", systemImage: "magnifyingglass") + } + NavigationLink(value: SettingsView.Path.indexers(instance.id)) { - Label("Indexers", systemImage: "magnifyingglass") + Label("Manage", systemImage: "list.bullet") } } } diff --git a/Ruddarr/Views/Settings/ProwlarrSearchRow.swift b/Ruddarr/Views/Settings/ProwlarrSearchRow.swift new file mode 100644 index 00000000..5a5ae251 --- /dev/null +++ b/Ruddarr/Views/Settings/ProwlarrSearchRow.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct ProwlarrSearchRow: View { + let release: ProwlarrRelease + + @EnvironmentObject var settings: AppSettings + + var body: some View { + VStack(alignment: .leading) { + Text(release.title) + .font(.headline) + .fontWeight(.semibold) + .lineLimit(1) + + secondRow + thirdRow + } + .contentShape(Rectangle()) + } + + var secondRow: some View { + HStack(spacing: 6) { + Text(release.sizeLabel) + Bullet() + Text(release.ageLabel) + } + .foregroundStyle(.secondary) + .lineLimit(1) + .font(.subheadline) + } + + var thirdRow: some View { + HStack(spacing: 6) { + Text(release.typeLabel) + .foregroundStyle(peerColor) + .truncationMode(.head) + + Group { + Bullet() + Text(release.indexerLabel) + } + .foregroundStyle(.secondary) + + Spacer() + } + .lineLimit(1) + .font(.subheadline) + } + + var peerColor: any ShapeStyle { + guard release.isTorrent else { return .green } + + return switch release.seeders ?? 0 { + case 50...: .green + case 10..<50: .blue + case 1..<10: .orange + default: .red + } + } +} + +#Preview { + let releases: [ProwlarrRelease] = PreviewData.load(name: "prowlarr-search") + + List { + ForEach(releases) { release in + ProwlarrSearchRow(release: release) + } + } + .listStyle(.inset) + .withAppState() +} diff --git a/Ruddarr/Views/Settings/ProwlarrSearchSheet.swift b/Ruddarr/Views/Settings/ProwlarrSearchSheet.swift new file mode 100644 index 00000000..5073bddb --- /dev/null +++ b/Ruddarr/Views/Settings/ProwlarrSearchSheet.swift @@ -0,0 +1,184 @@ +import SwiftUI + +struct ProwlarrSearchSheet: View { + var release: ProwlarrRelease + @Bindable var search: ProwlarrSearch + + @EnvironmentObject var settings: AppSettings + + @Environment(\.dismiss) private var dismiss + @Environment(\.deviceType) private var deviceType + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + @State private var isGrabbing: Bool = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading) { + header.padding(.bottom) + actions.padding(.bottom) + details + } + .scenePadding(.horizontal) + .padding(.top, deviceType == .mac ? 24 : (reduceTransparency ? 0 : -45)) + } + .toolbar { + ToolbarItem(placement: .destructiveAction) { + Button("Close", systemImage: "xmark") { dismiss() } + .hideIconOnMac() + .tint(.primary) + } + } + .alert( + isPresented: search.errorBinding, + error: search.error + ) { _ in + Button("OK") { search.error = nil } + } message: { error in + Text(error.recoverySuggestionFallback) + }.tint(nil) + } + } + + var header: some View { + VStack(alignment: .leading) { + Text(release.network.label) + .font(.footnote.weight(.semibold)) + .textCase(.uppercase) + .tracking(1.1) + .foregroundStyle(settings.theme.tint) + + Text(release.title.breakable()) + .font(.title2.bold()) + .kerning(-0.5) + .padding(.trailing, 56) + + HStack(spacing: 6) { + Text(release.sizeLabel) + Bullet() + Text(release.ageLabel) + } + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + if !release.categories.isEmpty { + categoryChips.padding(.top, 6) + } + } + } + + var categoryChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(release.categories) { category in + if let name = category.name { + Text(name) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.card) + .clipShape(Capsule()) + } + } + } + } + } + + var actions: some View { + HStack(spacing: 24) { + if deviceType != .phone { Spacer() } + + if let url = URL(string: release.infoUrl ?? "") { + Link(destination: url) { + let label: LocalizedStringKey = deviceType == .phone ? "Website" : "Open Website" + + ButtonLabel(text: label, icon: "arrow.up.right.square") + .modifier(MediaPreviewActionModifier()) + } + .buttonStyle(.bordered) + .tint(.buttonTint) + .contextMenu { LinkContextMenu(url) } + } + + Button { + Task { await grab() } + } label: { + let label: String = deviceType == .phone + ? String(localized: "Download", comment: "Short version of Download Release") + : String(localized: "Download Release") + + ButtonLabel(text: label, icon: "arrow.down.circle", isLoading: isGrabbing) + .modifier(MediaPreviewActionModifier()) + } + .buttonStyle(.bordered) + .tint(.buttonTint) + .allowsHitTesting(!isGrabbing) + + if deviceType != .phone { Spacer() } + } + .fixedSize(horizontal: false, vertical: true) + } + + var details: some View { + Section { + VStack(spacing: 6) { + row("Indexer", value: release.indexerLabel) + + if release.isTorrent { + Divider() + row("Peers", value: String(format: "S: %i L: %i", release.seeders ?? 0, release.leechers ?? 0)) + } + + if let grabs = release.grabs { + Divider() + row("Grabs", value: String(grabs)) + } + + if let publishDate = release.publishDate { + Divider() + row("Published", value: publishDate.formatted(date: .abbreviated, time: .omitted)) + } + } + .padding(.bottom) + } header: { + Text("Information") + .font(.title2.bold()) + } + } + + func row(_ label: LocalizedStringKey, value: String) -> some View { + HStack(alignment: .top) { + Text(label).foregroundStyle(.secondary) + Spacer(); Spacer(); Spacer() + Text(value).foregroundStyle(.primary) + } + .font(.subheadline) + .padding(.vertical, 4) + } + + func grab() async { + isGrabbing = true + let ok = await search.grab(release) + isGrabbing = false + + guard ok else { return } + dismiss() + dependencies.toast.show(.downloadQueued) + } +} + +#Preview { + let releases: [ProwlarrRelease] = PreviewData.load(name: "prowlarr-search") + let dummy = Instance.prowlarrDummy + let search = ProwlarrSearch(dummy) + + Text(verbatim: "Sheet") + .sheet(isPresented: .constant(true)) { + ProwlarrSearchSheet(release: releases[1], search: search) + .presentationDetents([.medium]) + .presentationBackground(.sheetBackground) + } + .withAppState() +} diff --git a/Ruddarr/Views/Settings/ProwlarrSearchView.swift b/Ruddarr/Views/Settings/ProwlarrSearchView.swift new file mode 100644 index 00000000..dfba9316 --- /dev/null +++ b/Ruddarr/Views/Settings/ProwlarrSearchView.swift @@ -0,0 +1,236 @@ +import SwiftUI + +struct ProwlarrSearchView: View { + let instance: Instance + @State var search: ProwlarrSearch + @State private var selectedRelease: ProwlarrRelease? + @State private var currentTask: Task? + @FocusState private var searchFocused: Bool + + @AppStorage("prowlarrSearchSort", store: dependencies.store) private var sort: ProwlarrSearchSort = .init() + + @EnvironmentObject var settings: AppSettings + @Environment(\.deviceType) private var deviceType + + init(instance: Instance) { + self.instance = instance + self._search = State(wrappedValue: ProwlarrSearch(instance)) + } + + var displayed: [ProwlarrRelease] { + sort.filterAndSortItems(search.items) + } + + var body: some View { + @Bindable var search = search + + List { + ForEach(displayed) { release in + Button { + searchFocused = false + selectedRelease = release + } label: { + ProwlarrSearchRow(release: release) + .environmentObject(settings) + } + .buttonStyle(.plain) + } + } + .listStyle(.inset) + .scrollDismissesKeyboard(.immediately) + .navigationTitle("Search Indexers") + .safeNavigationBarTitleDisplayMode(.inline) + .searchable( + text: $search.query, + placement: .drawerOrToolbar(.always), + prompt: Text("Search Prowlarr") + ) + .searchFocused($searchFocused) + // Keep sort/filter buttons reachable while the search field is focused; + // otherwise dismissing the field (via X) would clear an active query. + .searchPresentationToolbarBehavior(.avoidHidingContent) + .task { + if settings.releaseFilters == .reset { sort = .init() } + } + .onSubmit(of: .search) { startSearch() } + .onChange(of: search.category) { _, _ in + if !search.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + startSearch() + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { sortButton } + ToolbarItem(placement: .primaryAction) { filterButton } + } + .alert( + isPresented: search.errorBinding, + error: search.error + ) { _ in + Button("OK") { search.error = nil } + } message: { error in + Text(error.recoverySuggestionFallback) + }.tint(nil) + .overlay { + if search.isSearching { + SearchingIndicator() + } else if !search.hasSearched { + emptyState + } else if search.items.isEmpty { + noResults + } else if displayed.isEmpty { + noMatching + } + } + .sheet(item: $selectedRelease) { release in + ProwlarrSearchSheet(release: release, search: search) + .presentationDetents(dynamic: [deviceType == .phone ? .medium : .large]) + .presentationBackground(.sheetBackground) + .environmentObject(settings) + } + } + + var sortButton: some View { + Menu { + Section { + Picker("Sort By", selection: $sort.option) { + ForEach(ProwlarrSearchSort.Option.allCases) { option in + option.label.tag(option) + } + } + .pickerStyle(.inline) + } + + if sort.option != .byRelevance { + Section { + Picker("Direction", selection: $sort.isAscending) { + Label("Ascending", systemImage: "arrowtriangle.up").tag(true) + Label("Descending", systemImage: "arrowtriangle.down").tag(false) + }.pickerStyle(.inline) + } + } + } label: { + Image(systemName: "arrow.up.arrow.down") + .imageScale(.medium) + } + .tint(.primary) + .menuIndicator(.hidden) + } + + var filterButton: some View { + Menu { + categoryPicker + + if search.protocols.count > 1 { + protocolPicker + } + + if search.indexers.count > 1 { + indexerPicker + } + } label: { + if sort.hasFilter || search.category != .all { + Image("filters.badge") + .offset(y: 3) + .symbolRenderingMode(.palette) + .foregroundStyle(.tint, .primary) + } else { + Image(systemName: "line.3.horizontal.decrease") + } + } + .menuIndicator(.hidden) + } + + var categoryPicker: some View { + Menu { + Picker("Category", selection: $search.category) { + ForEach(ProwlarrSearchCategory.allCases) { category in + Text(category.label).tag(category) + } + } + .pickerStyle(.inline) + } label: { + Label( + search.category == .all ? String(localized: "Category") : search.category.label, + systemImage: "tag" + ) + } + } + + var protocolPicker: some View { + Menu { + Picker("Protocol", selection: $sort.network) { + Text("Any Protocol").tag(String.all) + + ForEach(search.protocols, id: \.self) { type in + Text(type).tag(Optional.some(type)) + } + } + .pickerStyle(.inline) + } label: { + Label( + sort.network == .all ? String(localized: "Protocol") : sort.network, + systemImage: "point.3.connected.trianglepath.dotted" + ) + } + } + + var indexerPicker: some View { + Menu { + Picker("Indexer", selection: $sort.indexer) { + Text("Any Indexer").tag(String.all) + + ForEach(search.indexers, id: \.self) { indexer in + Text(indexer).tag(Optional.some(indexer)) + } + } + .pickerStyle(.inline) + } label: { + Label( + sort.indexer == .all ? String(localized: "Indexer") : sort.indexer, + systemImage: "building.2" + ) + } + } + + var emptyState: some View { + ContentUnavailableView( + "Search Prowlarr", + systemImage: "magnifyingglass", + description: Text("Search across your enabled Prowlarr indexers. Grabs are sent to Prowlarr's download client and won't appear in Ruddarr's Activity.") + ) + } + + var noResults: some View { + ContentUnavailableView( + "No Releases Match", + systemImage: "slash.circle", + description: Text("No releases match \"\(search.query.trimmingCharacters(in: .whitespacesAndNewlines))\".") + ) + } + + var noMatching: some View { + ContentUnavailableView { + Label("No Releases Match", systemImage: "slash.circle") + } description: { + Text("No releases match the selected filters.") + } actions: { + Button("Clear Filters") { + sort.resetFilters() + } + } + } + + func startSearch() { + currentTask?.cancel() + currentTask = Task { + await search.search() + } + } +} + +#Preview { + NavigationStack { + ProwlarrSearchView(instance: .prowlarrDummy) + } + .withAppState() +} diff --git a/Ruddarr/Views/SettingsView.swift b/Ruddarr/Views/SettingsView.swift index 4cd0e189..29f704e1 100644 --- a/Ruddarr/Views/SettingsView.swift +++ b/Ruddarr/Views/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { case viewInstance(Instance.ID) case editInstance(Instance.ID) case indexers(Instance.ID) + case prowlarrSearch(Instance.ID) } var body: some View { @@ -62,6 +63,11 @@ struct SettingsView: View { IndexersView(instance: instance) .environmentObject(settings) } + case .prowlarrSearch(let instanceId): + if let instance = settings.instanceById(instanceId), instance.type == .prowlarr { + ProwlarrSearchView(instance: instance) + .environmentObject(settings) + } } } }