From f564134e06fe5e9254c757b4026aa6f4fbb2df32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till=20Kru=CC=88ss?= Date: Wed, 8 Apr 2026 08:05:11 -0700 Subject: [PATCH] wip --- Ruddarr.xcodeproj/project.pbxproj | 12 ++++ Ruddarr/Dependencies/API/API+Live.swift | 7 ++ Ruddarr/Dependencies/API/API+Mock.swift | 3 + Ruddarr/Dependencies/API/API.swift | 1 + Ruddarr/Models/Queue/CommandItem.swift | 74 ++++++++++++++++++++ Ruddarr/Models/Queue/Queue.swift | 11 +++ Ruddarr/Preview Content/commands.json | 69 ++++++++++++++++++ Ruddarr/Views/Activity/CommandListItem.swift | 33 +++++++++ Ruddarr/Views/ActivityView.swift | 40 +++++++++-- 9 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 Ruddarr/Models/Queue/CommandItem.swift create mode 100644 Ruddarr/Preview Content/commands.json create mode 100644 Ruddarr/Views/Activity/CommandListItem.swift diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 68e57aee..ec9ad00c 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 798010BD2B6268D200BBC056 /* RawRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798010BC2B6268D200BBC056 /* RawRepresentable.swift */; }; 798010BF2B6268DE00BBC056 /* MovieSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798010BE2B6268DE00BBC056 /* MovieSort.swift */; }; 79B761CB2B7115EC001DD30E /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B761CA2B7115EC001DD30E /* Toast.swift */; }; + AA0000012F00000000000001 /* CommandItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000012F00000000000002 /* CommandItem.swift */; }; + AA0000012F00000000000003 /* CommandListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000012F00000000000004 /* CommandListItem.swift */; }; + AA0000012F00000000000005 /* commands.json in Resources */ = {isa = PBXBuildFile; fileRef = AA0000012F00000000000006 /* commands.json */; }; BB000000000000000000AAA1 /* RuddarrTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000000000000000000AAA3 /* RuddarrTests.swift */; }; BB01B1F42B6465730025FAF4 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB01B1F32B6465730025FAF4 /* View.swift */; }; BB035A442C1D3B71005DFD45 /* Spotlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB035A432C1D3B71005DFD45 /* Spotlight.swift */; }; @@ -273,6 +276,9 @@ 798010BC2B6268D200BBC056 /* RawRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawRepresentable.swift; sourceTree = ""; }; 798010BE2B6268DE00BBC056 /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = ""; }; 79B761CA2B7115EC001DD30E /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; + AA0000012F00000000000002 /* CommandItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandItem.swift; sourceTree = ""; }; + AA0000012F00000000000004 /* CommandListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandListItem.swift; sourceTree = ""; }; + AA0000012F00000000000006 /* commands.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = commands.json; sourceTree = ""; }; BB000000000000000000AAA2 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; BB000000000000000000AAA3 /* RuddarrTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuddarrTests.swift; sourceTree = ""; }; BB01B1F32B6465730025FAF4 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; @@ -560,6 +566,7 @@ children = ( BBD5D8112C014538008E3B3F /* Queue.swift */, BB2370E12DCE76F500261710 /* QueueItem.swift */, + AA0000012F00000000000002 /* CommandItem.swift */, BB2370E32DCE76F600261710 /* ImportableFile.swift */, ); path = Queue; @@ -779,6 +786,7 @@ BBDBBC682C13A0600087C844 /* QueueSort.swift */, BB50F2262C3B06AD005E14CA /* QueueListItem.swift */, BBB8AED32C10DA1A00AA2D9C /* QueueItemSheet.swift */, + AA0000012F00000000000004 /* CommandListItem.swift */, BB3EBC002CC052B700141868 /* TaskRemovalView.swift */, BB42B7CA2DCD26AB00DA73A3 /* TaskImportView.swift */, ); @@ -912,6 +920,7 @@ BB2370D92DCE657800261710 /* sonarr-manual-import.json */, BBA06B582F47D4B600A5F9B4 /* popular-movies.json */, BB181D6E2F48E8DF00981037 /* popular-series.json */, + AA0000012F00000000000006 /* commands.json */, ); path = "Preview Content"; sourceTree = ""; @@ -1177,6 +1186,7 @@ BB181D6F2F48E8DF00981037 /* popular-series.json in Resources */, BB54D02A2E90932400F5CF42 /* AppIconWarp.icon in Resources */, BB77C2CE2C1A019B00125852 /* AppShortcuts.xcstrings in Resources */, + AA0000012F00000000000005 /* commands.json in Resources */, BBD5D8102C013E30008E3B3F /* movie-queue.json in Resources */, BBA733672BB5278800A5022B /* movie-history.json in Resources */, BB456D272B58E4B900C29B00 /* system-status.json in Resources */, @@ -1278,6 +1288,8 @@ BB6F23AD2B6ABBBD00A4347A /* SettingsSystemSection.swift in Sources */, BB0FE1112BED30D100D1D847 /* SeriesFiles.swift in Sources */, BB8A56792C0004D500199DB7 /* Reviews.swift in Sources */, + AA0000012F00000000000001 /* CommandItem.swift in Sources */, + AA0000012F00000000000003 /* CommandListItem.swift in Sources */, BBD5D8122C014538008E3B3F /* Queue.swift in Sources */, BBC309D82BED584C004080FD /* Platform.swift in Sources */, BB96C32B2BE833AD00E24C1C /* SeriesForm.swift in Sources */, diff --git a/Ruddarr/Dependencies/API/API+Live.swift b/Ruddarr/Dependencies/API/API+Live.swift index 694c7553..6b20824c 100644 --- a/Ruddarr/Dependencies/API/API+Live.swift +++ b/Ruddarr/Dependencies/API/API+Live.swift @@ -221,6 +221,13 @@ extension API { .appending(path: "/api/v3/command") return try await request(method: .post, url: url, headers: instance.auth, body: command.payload) + }, fetchCommands: { instance in + let url = try instance.baseURL() + .appending(path: "/api/v3/command") + + var commands: [CommandItem] = try await request(url: url, headers: instance.auth) + for i in commands.indices { commands[i].instanceId = instance.id } + return commands }, downloadRelease: { payload, instance in let url = try instance.baseURL() .appending(path: "/api/v3/release") diff --git a/Ruddarr/Dependencies/API/API+Mock.swift b/Ruddarr/Dependencies/API/API+Mock.swift index d2c13f27..53faef8e 100644 --- a/Ruddarr/Dependencies/API/API+Mock.swift +++ b/Ruddarr/Dependencies/API/API+Mock.swift @@ -128,6 +128,9 @@ extension API { try await Task.sleep(for: .seconds(2)) return Empty() + }, fetchCommands: { instance in + let commands: [CommandItem] = loadPreviewData(filename: "commands") + return commands.map { var cmd = $0; cmd.instanceId = instance.id; return cmd } }, downloadRelease: { _, _ in try await Task.sleep(for: .seconds(1)) diff --git a/Ruddarr/Dependencies/API/API.swift b/Ruddarr/Dependencies/API/API.swift index 34f5a4d7..ce6234c1 100644 --- a/Ruddarr/Dependencies/API/API.swift +++ b/Ruddarr/Dependencies/API/API.swift @@ -36,6 +36,7 @@ struct API { var episodeCalendar: (Date, Date, Instance) async throws -> [Episode] var command: (InstanceCommand, Instance) async throws -> Empty + var fetchCommands: (Instance) async throws -> [CommandItem] var downloadRelease: (DownloadReleaseCommand, Instance) async throws -> Empty var systemStatus: (Instance) async throws -> InstanceStatus diff --git a/Ruddarr/Models/Queue/CommandItem.swift b/Ruddarr/Models/Queue/CommandItem.swift new file mode 100644 index 00000000..3ae9978e --- /dev/null +++ b/Ruddarr/Models/Queue/CommandItem.swift @@ -0,0 +1,74 @@ +import Foundation + +struct CommandItem: Codable, Identifiable, Equatable { + let id: Int + let name: String + let commandName: String + let status: CommandStatus + let queued: Date + let started: Date? + let ended: Date? + let message: String? + let trigger: CommandTrigger + + var instanceId: Instance.ID? + + enum CodingKeys: String, CodingKey { + case id + case name + case commandName + case status + case queued + case started + case ended + case message + case trigger + } + + static func == (lhs: CommandItem, rhs: CommandItem) -> Bool { + lhs.id == rhs.id && + lhs.instanceId == rhs.instanceId && + lhs.status == rhs.status && + lhs.message == rhs.message + } + + var titleLabel: String { + commandName + } + + var statusLabel: String { + switch status { + case .queued: String(localized: "Queued", comment: "State of running command") + case .started: String(localized: "Running", comment: "State of running command") + case .completed: String(localized: "Completed", comment: "State of running command") + case .failed: String(localized: "Failed", comment: "State of running command") + case .cancelled: String(localized: "Cancelled", comment: "State of running command") + case .aborted: String(localized: "Aborted", comment: "State of running command") + } + } + + var triggerLabel: String { + switch trigger { + case .manual: String(localized: "Manual", comment: "Command trigger type") + case .scheduled: String(localized: "Scheduled", comment: "Command trigger type") + } + } + + var isActive: Bool { + status == .queued || status == .started + } +} + +enum CommandStatus: String, Codable { + case queued + case started + case completed + case failed + case cancelled + case aborted +} + +enum CommandTrigger: String, Codable { + case manual + case scheduled +} diff --git a/Ruddarr/Models/Queue/Queue.swift b/Ruddarr/Models/Queue/Queue.swift index 078cf790..b08fa0bd 100644 --- a/Ruddarr/Models/Queue/Queue.swift +++ b/Ruddarr/Models/Queue/Queue.swift @@ -14,6 +14,7 @@ class Queue { var instances: [Instance] = [] var items: [Instance.ID: [QueueItem]] = [:] + var commands: [Instance.ID: [CommandItem]] = [:] var itemsWithIssues: Int = 0 private init() { @@ -50,6 +51,16 @@ class Queue { } catch { self.error = API.Error(from: error) } + + // TODO: this should happen in parallel + do { + let allCommands = try await dependencies.api.fetchCommands(instance) + commands[instance.id] = allCommands.filter { $0.isActive } + } catch is CancellationError { + // do nothing + } catch { + leaveBreadcrumb(.warning, category: "queue", message: "Commands fetch failed", data: ["error": error]) + } } let issues = items.flatMap { $0.value }.filter { $0.hasIssue } diff --git a/Ruddarr/Preview Content/commands.json b/Ruddarr/Preview Content/commands.json new file mode 100644 index 00000000..9f1c396d --- /dev/null +++ b/Ruddarr/Preview Content/commands.json @@ -0,0 +1,69 @@ +[ + { + "id": 1, + "name": "MissingMoviesSearch", + "commandName": "Missing Movies Search", + "status": "started", + "queued": "2026-04-08T10:00:00Z", + "started": "2026-04-08T10:00:01Z", + "ended": null, + "message": "Searching 42 movies", + "trigger": "scheduled" + }, + { + "id": 2, + "name": "RssSync", + "commandName": "RSS Sync", + "status": "started", + "queued": "2026-04-08T10:01:00Z", + "started": "2026-04-08T10:01:02Z", + "ended": null, + "message": null, + "trigger": "scheduled" + }, + { + "id": 2397948, + "name": "RefreshMovie", + "commandName": "Refresh Movie", + "status": "queued", + "queued": "2026-04-08T10:02:00Z", + "started": null, + "ended": null, + "message": "Updating info for Going in Style", + "trigger": "manual", + "body": { + "movieIds": [570, 440, 645, 429, 137, 381, 296, 769, 666], + "isNewMovie": false, + "sendUpdatesToClient": true, + "updateScheduledTask": false, + "isLongRunning": true, + "completionMessage": "Completed", + "requiresDiskAccess": false, + "isExclusive": false, + "isTypeExclusive": false, + "name": "RefreshMovie", + "trigger": "manual", + "suppressMessages": false + }, + "priority": "normal", + "status": "started", + "result": "unknown", + "queued": "2026-04-08T14:57:04Z", + "started": "2026-04-08T14:57:04Z", + "trigger": "manual", + "stateChangeTime": "2026-04-08T14:57:04Z", + "sendUpdatesToClient": true, + "updateScheduledTask": false, + }, + { + "id": 5, + "name": "MissingEpisodeSearch", + "commandName": "Missing Episode Search", + "status": "started", + "queued": "2026-04-08T10:04:00Z", + "started": "2026-04-08T10:04:02Z", + "ended": null, + "message": "Searching 17 episodes", + "trigger": "scheduled" + } +] diff --git a/Ruddarr/Views/Activity/CommandListItem.swift b/Ruddarr/Views/Activity/CommandListItem.swift new file mode 100644 index 00000000..e4657dc5 --- /dev/null +++ b/Ruddarr/Views/Activity/CommandListItem.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct CommandListItem: View { + var command: CommandItem + + var body: some View { + VStack(alignment: .leading) { + Text(command.titleLabel) + .font(.headline) + .fontWeight(.semibold) + .lineLimit(1) + + HStack(spacing: 6) { + Text(command.statusLabel) + + Bullet() + Text(command.triggerLabel) + + if let message = command.message, !message.isEmpty { + Bullet() + Text(message) + .lineLimit(1) + .truncationMode(.tail) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } +} diff --git a/Ruddarr/Views/ActivityView.swift b/Ruddarr/Views/ActivityView.swift index 8ac93bf9..2b477ac9 100644 --- a/Ruddarr/Views/ActivityView.swift +++ b/Ruddarr/Views/ActivityView.swift @@ -11,13 +11,27 @@ struct ActivityView: View { @Environment(\.deviceType) private var deviceType var body: some View { - // swiftlint:disable:next closure_body_length NavigationStack { Group { if settings.configuredInstances.isEmpty { NoInstance() } else { List { + if !activeCommands.isEmpty { + Section { + ForEach(activeCommands) { command in + CommandListItem(command: command) + } + #if os(macOS) + .padding(.vertical, 4) + #else + .listRowBackground(Color.card) + #endif + } header: { + commandsSectionHeader + } + } + Section { ForEach(items) { item in Button { @@ -33,7 +47,7 @@ struct ActivityView: View { .listRowBackground(Color.card) #endif } header: { - if !items.isEmpty { sectionHeader } + if !items.isEmpty { queueSectionHeader } } } #if os(iOS) @@ -41,7 +55,7 @@ struct ActivityView: View { #endif .scrollContentBackground(.hidden) .overlay { - if items.isEmpty { + if items.isEmpty && activeCommands.isEmpty { queueEmpty } } @@ -81,6 +95,24 @@ struct ActivityView: View { } } + var activeCommands: [CommandItem] { + var cmds = queue.commands.flatMap { $0.value } + + if sort.instance != .all { + cmds = cmds.filter { + $0.instanceId?.isEqual(to: sort.instance) == true + } + } + + return cmds.sorted { $0.queued < $1.queued } + } + + var commandsSectionHeader: some View { + HStack(spacing: 6) { + Text("\(activeCommands.count) Running") + } + } + var queueEmpty: some View { ContentUnavailableView( "Queues Empty", @@ -89,7 +121,7 @@ struct ActivityView: View { ) } - var sectionHeader: some View { + var queueSectionHeader: some View { HStack(spacing: 6) { Text("\(items.count) Task")