diff --git a/Ruddarr.xcodeproj/project.pbxproj b/Ruddarr.xcodeproj/project.pbxproj index 98ed05f7..ec6ef08c 100644 --- a/Ruddarr.xcodeproj/project.pbxproj +++ b/Ruddarr.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 1AFC24FA2F82B753009FF858 /* Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AFC24F82F82B753009FF858 /* Commands.swift */; }; + 1AFC24FF2F82B789009FF858 /* CommandSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AFC24FD2F82B789009FF858 /* CommandSheet.swift */; }; + 1AFC25012F82B789009FF858 /* CommandListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AFC24FC2F82B789009FF858 /* CommandListItem.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 */; }; @@ -260,6 +263,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1AFC24F82F82B753009FF858 /* Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Commands.swift; path = Commands/Commands.swift; sourceTree = ""; }; + 1AFC24FC2F82B789009FF858 /* CommandListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandListItem.swift; sourceTree = ""; }; + 1AFC24FD2F82B789009FF858 /* CommandSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandSheet.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 = ""; }; @@ -773,6 +779,8 @@ BBB8AED22C10DA0F00AA2D9C /* Activity */ = { isa = PBXGroup; children = ( + 1AFC24FC2F82B789009FF858 /* CommandListItem.swift */, + 1AFC24FD2F82B789009FF858 /* CommandSheet.swift */, 2B949CEA2CCBC8600088B1A8 /* HistoryView.swift */, BB50F2282C3B3322005E14CA /* ActivityView+Toolbar.swift */, BBDBBC682C13A0600087C844 /* QueueSort.swift */, @@ -918,6 +926,7 @@ BBF477A62B4F8AA300C2DED3 /* Models */ = { isa = PBXGroup; children = ( + 1AFC24F82F82B753009FF858 /* Commands.swift */, BB2370DC2DCE76A600261710 /* Queue */, BBF583C02BACA40D00AFA7FB /* Movies */, BB507D202BD9702B00EC4016 /* Series */, @@ -1224,6 +1233,7 @@ BBE7CE032B91745100431801 /* MovieDetails+Information.swift in Sources */, 2B949CEB2CCBC8690088B1A8 /* HistoryView.swift in Sources */, 79159D6E2B5953E800F7F997 /* API.swift in Sources */, + 1AFC24FA2F82B753009FF858 /* Commands.swift in Sources */, BB05C9052B86D0EC009B6444 /* Languages.swift in Sources */, BBC136A42B62DD780074C7AA /* Network.swift in Sources */, BB89ABC62B756B91009FB62D /* MovieReleaseRow.swift in Sources */, @@ -1285,6 +1295,8 @@ BBD3F2D82BFA578000DE5D8E /* MediaHistory.swift in Sources */, BBA50F4F2D760CAA008A16FA /* SettingsIconLabelStyle.swift in Sources */, BBE1E43F2B51F61700946222 /* MovieLookup.swift in Sources */, + 1AFC24FF2F82B789009FF858 /* CommandSheet.swift in Sources */, + 1AFC25012F82B789009FF858 /* CommandListItem.swift in Sources */, BBE564DF2DC99A8C005C26B6 /* MovieGridCard.swift in Sources */, 2B949CE72CC92F370088B1A8 /* History.swift in Sources */, BBAED12D2B72D444006C6CF2 /* ButtonLabel.swift in Sources */, diff --git a/Ruddarr/Dependencies/API/API+Live.swift b/Ruddarr/Dependencies/API/API+Live.swift index 694c7553..10bc1193 100644 --- a/Ruddarr/Dependencies/API/API+Live.swift +++ b/Ruddarr/Dependencies/API/API+Live.swift @@ -220,7 +220,26 @@ extension API { let url = try instance.baseURL() .appending(path: "/api/v3/command") - return try await request(method: .post, url: url, headers: instance.auth, body: command.payload) + var status: InstanceCommandStatus = try await request( + method: .post, url: url, headers: instance.auth, body: command.payload + ) + status.instanceId = instance.id + return status + }, fetchCommand: { id, instance in + let url = try instance.baseURL() + .appending(path: "/api/v3/command") + .appending(path: String(id)) + + var status: InstanceCommandStatus = try await request(url: url, headers: instance.auth) + status.instanceId = instance.id + return status + }, fetchCommands: { instance in + let url = try instance.baseURL() + .appending(path: "/api/v3/command") + + var statuses: [InstanceCommandStatus] = try await request(url: url, headers: instance.auth) + for i in statuses.indices { statuses[i].instanceId = instance.id } + return statuses }, 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..1a8f7ba6 100644 --- a/Ruddarr/Dependencies/API/API+Mock.swift +++ b/Ruddarr/Dependencies/API/API+Mock.swift @@ -124,10 +124,42 @@ extension API { let episodes: [Episode] = loadPreviewData(filename: "calendar-episodes") return modifyCalendarEpisodes(episodes, instance) - }, command: { _, _ in - try await Task.sleep(for: .seconds(2)) - - return Empty() + }, command: { cmd, instance in + var status = InstanceCommandStatus( + commandId: Int.random(in: 1...999), + name: cmd.payload.name, + commandName: nil, + message: "Queued", + status: "queued", + result: nil, + queued: Date(), + started: nil, + ended: nil, + trigger: "manual", + instanceId: nil, + subject: nil + ) + status.instanceId = instance.id + return status + }, fetchCommand: { id, instance in + var status = InstanceCommandStatus( + commandId: id, + name: "MoviesSearch", + commandName: nil, + message: "Completed", + status: "completed", + result: "successful", + queued: Date().addingTimeInterval(-10), + started: Date().addingTimeInterval(-9), + ended: Date().addingTimeInterval(-1), + trigger: "manual", + instanceId: nil, + subject: nil + ) + status.instanceId = instance.id + return status + }, fetchCommands: { _ in + [] }, 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..9b3610b2 100644 --- a/Ruddarr/Dependencies/API/API.swift +++ b/Ruddarr/Dependencies/API/API.swift @@ -35,7 +35,9 @@ struct API { var movieCalendar: (Date, Date, Instance) async throws -> [Movie] var episodeCalendar: (Date, Date, Instance) async throws -> [Episode] - var command: (InstanceCommand, Instance) async throws -> Empty + var command: (InstanceCommand, Instance) async throws -> InstanceCommandStatus + var fetchCommand: (Int, Instance) async throws -> InstanceCommandStatus + var fetchCommands: (Instance) async throws -> [InstanceCommandStatus] var downloadRelease: (DownloadReleaseCommand, Instance) async throws -> Empty var systemStatus: (Instance) async throws -> InstanceStatus diff --git a/Ruddarr/Models/Commands/Commands.swift b/Ruddarr/Models/Commands/Commands.swift new file mode 100644 index 00000000..2c963898 --- /dev/null +++ b/Ruddarr/Models/Commands/Commands.swift @@ -0,0 +1,216 @@ +import Foundation +import SwiftUI + +struct InstanceCommandStatus: Identifiable, Codable, Equatable, Hashable { + let commandId: Int + let name: String + let commandName: String? + var message: String? + var status: String + var result: String? + let queued: Date + var started: Date? + var ended: Date? + let trigger: String? + + var instanceId: Instance.ID? + var subject: String? + + enum CodingKeys: String, CodingKey { + case commandId = "id" + case name, commandName, message, status, result + case queued, started, ended, trigger + } + + var id: String { + "\(instanceId?.uuidString ?? "none")-\(commandId)" + } + + var state: CommandStatusState { + CommandStatusState(rawValue: status) ?? .unknown + } + + var isTerminal: Bool { + switch state { + case .completed, .failed, .aborted, .cancelled, .orphaned: + return true + case .queued, .started, .unknown: + return false + } + } + + var isSearchCommand: Bool { + Self.searchCommandNames.contains(name) + } + + static let searchCommandNames: Set = [ + "MoviesSearch", + "SeriesSearch", + "SeasonSearch", + "EpisodeSearch", + ] + + static let visibleCommandNames: Set = [ + "MoviesSearch", + "MissingMoviesSearch", + "CutoffUnmetMoviesSearch", + "EpisodeSearch", + "SeasonSearch", + "SeriesSearch", + "MissingEpisodeSearch", + "CutoffUnmetEpisodeSearch", + "RefreshMovie", + "RefreshSeries", + "RefreshCollections", + "RssSync", + "ManualImport", + "RenameFiles", + "RenameMovie", + "RenameSeries", + "RescanMovie", + "RescanSeries", + ] + + var sortDate: Date { + started ?? queued + } + + var displayTitle: String { + subject ?? commandName ?? name + } +} + +enum CommandStatusState: String, Codable { + case queued + case started + case completed + case failed + case aborted + case cancelled + case orphaned + case unknown + + var label: String { + switch self { + case .queued: String(localized: "Queued", comment: "Command status") + case .started: String(localized: "Running", comment: "Command status") + case .completed: String(localized: "Completed", comment: "Command status") + case .failed: String(localized: "Failed", comment: "Command status") + case .aborted: String(localized: "Aborted", comment: "Command status") + case .cancelled: String(localized: "Cancelled", comment: "Command status") + case .orphaned: String(localized: "Orphaned", comment: "Command status") + case .unknown: String(localized: "Unknown", comment: "Command status") + } + } + + var systemImage: String { + switch self { + case .queued: "clock" + case .started: "arrow.triangle.2.circlepath" + case .completed: "checkmark.circle" + case .failed, .aborted, .orphaned: "exclamationmark.triangle" + case .cancelled: "xmark.circle" + case .unknown: "questionmark.circle" + } + } + + var tint: Color { + switch self { + case .completed: .green + case .failed, .aborted, .orphaned: .red + case .cancelled: .secondary + case .queued, .started: .accentColor + case .unknown: .secondary + } + } +} + +@MainActor +@Observable +class Commands { + static let shared = Commands() + + private var timer: Timer? + + var error: API.Error? + + var isLoading: Bool = false + var performRefresh: Bool = false + + var instances: [Instance] = [] + var items: [Instance.ID: [InstanceCommandStatus]] = [:] + + private let perInstanceLimit = 50 + + private init() { + let interval: TimeInterval = isRunningIn(.preview) ? 30 : 5 + + self.timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + Task { + if await self.performRefresh { + await self.fetchAll() + } + } + } + } + + func track(_ status: InstanceCommandStatus) { + guard let instanceId = status.instanceId else { return } + var list = items[instanceId] ?? [] + list.removeAll { $0.id == status.id } + list.insert(status, at: 0) + items[instanceId] = Array(list.prefix(perInstanceLimit)) + } + + func merge(_ incoming: [InstanceCommandStatus], for instanceId: Instance.ID) { + let visible = incoming.filter { InstanceCommandStatus.visibleCommandNames.contains($0.name) } + var existing = items[instanceId] ?? [] + let existingById = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + + for var fresh in visible { + if let prior = existingById[fresh.id], fresh.subject == nil { + fresh.subject = prior.subject + } + if let idx = existing.firstIndex(where: { $0.id == fresh.id }) { + existing[idx] = fresh + } else { + existing.append(fresh) + } + } + + existing.sort { $0.sortDate > $1.sortDate } + items[instanceId] = Array(existing.prefix(perInstanceLimit)) + } + + func fetchAll() async { + guard !isLoading else { return } + + error = nil + isLoading = true + + for instance in instances { + do { + let statuses = try await dependencies.api.fetchCommands(instance) + merge(statuses, for: instance.id) + } catch is CancellationError { + // do nothing + } catch let apiError as API.Error { + error = apiError + + leaveBreadcrumb(.error, category: "commands", message: "Fetch failed", data: ["error": apiError]) + } catch { + self.error = API.Error(from: error) + } + } + + isLoading = false + } + + func filteredItems(showAll: Bool) -> [InstanceCommandStatus] { + let all = items.values.flatMap { $0 } + .filter { InstanceCommandStatus.visibleCommandNames.contains($0.name) } + .filter { !$0.isTerminal || ($0.ended ?? $0.started ?? $0.queued) >= Date().addingTimeInterval(-300) } + let filtered = showAll ? all : all.filter { $0.isSearchCommand } + return filtered.sorted { $0.sortDate > $1.sortDate } + } +} diff --git a/Ruddarr/Models/Movies/Movies.swift b/Ruddarr/Models/Movies/Movies.swift index a74e4a8c..2178f49a 100644 --- a/Ruddarr/Models/Movies/Movies.swift +++ b/Ruddarr/Models/Movies/Movies.swift @@ -99,8 +99,27 @@ class Movies { await request(.download(guid, indexerId, movieId)) } - func command(_ command: InstanceCommand) async -> Bool { - await request(.command(command)) + func command(_ command: InstanceCommand) async -> InstanceCommandStatus? { + error = nil + isWorking = true + defer { isWorking = false } + + do { + return try await dependencies.api.command(command, instance) + } catch is CancellationError { + return nil + } catch let apiError as API.Error { + error = apiError + leaveBreadcrumb( + .error, category: "movies", + message: "Command failed", + data: ["command": command, "error": apiError] + ) + return nil + } catch { + self.error = API.Error(from: error) + return nil + } } func request(_ operation: Operation) async -> Bool { diff --git a/Ruddarr/Models/Series/SeriesModel.swift b/Ruddarr/Models/Series/SeriesModel.swift index 0ecd5cc3..d99491ed 100644 --- a/Ruddarr/Models/Series/SeriesModel.swift +++ b/Ruddarr/Models/Series/SeriesModel.swift @@ -108,8 +108,27 @@ class SeriesModel { await request(.download(guid, indexerId, seriesId, seasonId, episodeId)) } - func command(_ command: InstanceCommand) async -> Bool { - await request(.command(command)) + func command(_ command: InstanceCommand) async -> InstanceCommandStatus? { + error = nil + isWorking = true + defer { isWorking = false } + + do { + return try await dependencies.api.command(command, instance) + } catch is CancellationError { + return nil + } catch let apiError as API.Error { + error = apiError + leaveBreadcrumb( + .error, category: "series", + message: "Command failed", + data: ["command": command, "error": apiError] + ) + return nil + } catch { + self.error = API.Error(from: error) + return nil + } } func request(_ operation: Operation, silent: Bool = false) async -> Bool { diff --git a/Ruddarr/Utilities/Formatters.swift b/Ruddarr/Utilities/Formatters.swift index 2f248eaa..da125b91 100644 --- a/Ruddarr/Utilities/Formatters.swift +++ b/Ruddarr/Utilities/Formatters.swift @@ -29,6 +29,14 @@ func formatIndexer(_ name: String) -> String { } } +func formatDuration(_ seconds: TimeInterval) -> String { + let totalSeconds = max(0, Int(seconds)) + if totalSeconds < 60 { return "\(totalSeconds)s" } + let minutes = totalSeconds / 60 + let remainingSeconds = totalSeconds % 60 + return "\(minutes)m \(remainingSeconds)s" +} + func formatRuntime(_ minutes: Int) -> String? { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] diff --git a/Ruddarr/Views/Activity/CommandListItem.swift b/Ruddarr/Views/Activity/CommandListItem.swift new file mode 100644 index 00000000..228cd5b6 --- /dev/null +++ b/Ruddarr/Views/Activity/CommandListItem.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct CommandListItem: View { + var command: InstanceCommandStatus + + @State private var time = Date() + private let timer = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(alignment: .leading) { + Text(command.displayTitle) + .font(.headline.monospacedDigit()) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + HStack(spacing: 6) { + Image(systemName: command.state.systemImage) + .imageScale(.small) + Text(command.state.label) + + if let subline { + Bullet() + Text(subline) + .monospacedDigit() + .id(time) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onReceive(timer) { _ in + if command.state == .started || command.state == .queued { + withAnimation { + time = Date() + } + } + } + } + + var subline: String? { + switch command.state { + case .started, .queued: + let elapsed = time.timeIntervalSince(command.started ?? command.queued) + return formatDuration(elapsed) + case .completed, .failed, .aborted, .cancelled, .orphaned, .unknown: + guard let ended = command.ended else { return nil } + return ended.formatted(.relative(presentation: .named)) + } + } +} + +#Preview { + dependencies.router.selectedTab = .activity + + return ContentView() + .withAppState() +} diff --git a/Ruddarr/Views/Activity/CommandSheet.swift b/Ruddarr/Views/Activity/CommandSheet.swift new file mode 100644 index 00000000..786cdbd6 --- /dev/null +++ b/Ruddarr/Views/Activity/CommandSheet.swift @@ -0,0 +1,132 @@ +import SwiftUI + +struct CommandSheet: View { + var command: InstanceCommandStatus + + @EnvironmentObject var settings: AppSettings + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading) { + header + + details + .padding(.top) + } + .scenePadding(.horizontal) + #if os(macOS) + .padding(.top, 24) + #else + .offset(y: -45) + #endif + } + .toolbar { + ToolbarItem(placement: .destructiveAction) { + Button("Close", systemImage: "xmark") { + dismiss() + } + .hideIconOnMac() + .tint(.primary) + } + } + } + } + + @ViewBuilder + var header: some View { + HStack(spacing: 6) { + Image(systemName: command.state.systemImage) + Text(command.state.label) + } + .foregroundStyle(command.state.tint) + .font(.caption) + .fontWeight(.semibold) + .textCase(.uppercase) + .tracking(1.1) + + Text(command.displayTitle) + .font(.title3.bold()) + .kerning(-0.5) + .fixedSize(horizontal: false, vertical: true) + .padding(.trailing, 56) + } + + var details: some View { + Section { + VStack(spacing: 6) { + row("Instance", instanceLabel) + + Divider() + row("Command", command.commandName ?? command.name) + + if let result = command.result { + Divider() + row("Result", result) + } + + if let message = command.message { + Divider() + row("Message", message) + } + + Divider() + row("Queued", command.queued.formatted(date: .long, time: .shortened)) + + if let started = command.started { + Divider() + row("Started", started.formatted(date: .long, time: .shortened)) + } + + if let ended = command.ended { + Divider() + row("Ended", ended.formatted(date: .long, time: .shortened)) + } + + if let started = command.started, let ended = command.ended { + Divider() + row("Duration", formatDuration(ended.timeIntervalSince(started))) + } + } + .padding(.bottom) + } header: { + Text("Information") + .font(.title2.bold()) + } + } + + func row(_ label: LocalizedStringKey, _ value: String) -> some View { + row(label, Text(value).foregroundStyle(.primary)) + } + + func row(_ label: LocalizedStringKey, _ value: V) -> some View { + HStack(alignment: .top) { + Text(label) + .foregroundStyle(.secondary) + + Spacer() + Spacer() + Spacer() + + value + .multilineTextAlignment(.trailing) + } + .font(.callout) + .padding(.vertical, 4) + } + + var instanceLabel: String { + guard let id = command.instanceId, + let instance = settings.instances.first(where: { $0.id == id }) + else { return "—" } + return instance.label + } +} + +#Preview { + dependencies.router.selectedTab = .activity + + return ContentView() + .withAppState() +} diff --git a/Ruddarr/Views/ActivityView.swift b/Ruddarr/Views/ActivityView.swift index 8ac93bf9..5d4c8e98 100644 --- a/Ruddarr/Views/ActivityView.swift +++ b/Ruddarr/Views/ActivityView.swift @@ -2,22 +2,44 @@ import SwiftUI struct ActivityView: View { @State var queue = Queue.shared + @State var commands = Commands.shared @State var sort: QueueSort = .init() @State var items: [QueueItem] = [] @State private var selectedItem: QueueItem? + @State private var selectedCommand: InstanceCommandStatus? @EnvironmentObject var settings: AppSettings @Environment(\.deviceType) private var deviceType var body: some View { - // swiftlint:disable:next closure_body_length NavigationStack { + // swiftlint:disable:next closure_body_length Group { if settings.configuredInstances.isEmpty { NoInstance() } else { List { + if !commandItems.isEmpty { + Section { + ForEach(commandItems) { command in + Button { + selectedCommand = command + } label: { + CommandListItem(command: command) + } + .buttonStyle(.plain) + } + #if os(macOS) + .padding(.vertical, 4) + #else + .listRowBackground(Color.card) + #endif + } header: { + commandsSectionHeader + } + } + Section { ForEach(items) { item in Button { @@ -33,7 +55,7 @@ struct ActivityView: View { .listRowBackground(Color.card) #endif } header: { - if !items.isEmpty { sectionHeader } + if !items.isEmpty { queueSectionHeader } } } #if os(iOS) @@ -41,7 +63,7 @@ struct ActivityView: View { #endif .scrollContentBackground(.hidden) .overlay { - if items.isEmpty { + if items.isEmpty && commandItems.isEmpty { queueEmpty } } @@ -55,19 +77,27 @@ struct ActivityView: View { .onChange(of: sort, updateDisplayedItems) .onChange(of: queue.items, updateDisplayedItems) .onChange(of: queue.items, updateSelectedItem) + .onChange(of: commands.items, updateSelectedCommand) .onAppear { queue.instances = settings.instances queue.performRefresh = true + commands.instances = settings.instances + commands.performRefresh = true updateDisplayedItems() } .onDisappear { queue.performRefresh = false + commands.performRefresh = false } .task { await queue.fetchTasks() } + .task { + await commands.fetchAll() + } .refreshable { Task { await queue.refreshDownloadClients() } + Task { await commands.fetchAll() } await Task { await queue.fetchTasks() }.value } .sheet(item: $selectedItem) { item in @@ -78,6 +108,36 @@ struct ActivityView: View { .presentationBackground(.sheetBackground) .environmentObject(settings) } + .sheet(item: $selectedCommand) { command in + CommandSheet(command: command) + .presentationDetents(dynamic: [ + deviceType == .phone ? .fraction(0.7) : .large + ]) + .presentationBackground(.sheetBackground) + .environmentObject(settings) + } + } + } + + var commandItems: [InstanceCommandStatus] { + var cmds = commands.filteredItems(showAll: true) + if sort.instance != .all { + cmds = cmds.filter { + $0.instanceId?.isEqual(to: sort.instance) == true + } + } + return cmds + } + + var commandsSectionHeader: some View { + HStack(spacing: 6) { + Text("\(commandItems.count) Running") + + if commands.isLoading { + ProgressView() + .controlSize(.small) + .tint(.secondary) + } } } @@ -89,7 +149,7 @@ struct ActivityView: View { ) } - var sectionHeader: some View { + var queueSectionHeader: some View { HStack(spacing: 6) { Text("\(items.count) Task") @@ -116,6 +176,17 @@ struct ActivityView: View { } } + func updateSelectedCommand() { + guard let commandId = selectedCommand?.commandId else { return } + guard let instanceId = selectedCommand?.instanceId else { return } + + if let command = commands.items[instanceId]?.first(where: { $0.commandId == commandId }) { + selectedCommand = command + } else { + selectedCommand = nil + } + } + func updateDisplayedItems() { let grouped: [String: [QueueItem]] = Dictionary( grouping: queue.items.flatMap { $0.value }, diff --git a/Ruddarr/Views/Movies/MovieContextMenu.swift b/Ruddarr/Views/Movies/MovieContextMenu.swift index 58f2321f..a00e6904 100644 --- a/Ruddarr/Views/Movies/MovieContextMenu.swift +++ b/Ruddarr/Views/Movies/MovieContextMenu.swift @@ -19,10 +19,13 @@ struct MovieContextMenu: View { } func dispatchSearch() async { - guard await instance.movies.command(.search([movie.id])) else { + guard var status = await instance.movies.command(.search([movie.id])) else { return } + status.subject = movie.title + Commands.shared.track(status) + dependencies.toast.show(.movieSearchQueued) Telemetry.record(.movieSearchDispatched) diff --git a/Ruddarr/Views/Movies/MovieDetails.swift b/Ruddarr/Views/Movies/MovieDetails.swift index 314b9890..d470ceb4 100644 --- a/Ruddarr/Views/Movies/MovieDetails.swift +++ b/Ruddarr/Views/Movies/MovieDetails.swift @@ -190,12 +190,15 @@ struct MovieDetails: View { defer { dispatchingSearch = false } dispatchingSearch = true - guard await instance.movies.command( + guard var status = await instance.movies.command( .search([movie.id]) ) else { return } + status.subject = movie.title + Commands.shared.track(status) + dependencies.toast.show(.movieSearchQueued) Telemetry.record(.movieSearchDispatched) diff --git a/Ruddarr/Views/Movies/MovieView.swift b/Ruddarr/Views/Movies/MovieView.swift index 200cd051..9e30293e 100644 --- a/Ruddarr/Views/Movies/MovieView.swift +++ b/Ruddarr/Views/Movies/MovieView.swift @@ -153,7 +153,7 @@ extension MovieView { } func refresh() async { - guard await instance.movies.command(.refreshMovie([movie.id])) else { + guard await instance.movies.command(.refreshMovie([movie.id])) != nil else { return } @@ -165,10 +165,13 @@ extension MovieView { } func dispatchSearch() async { - guard await instance.movies.command(.search([movie.id])) else { + guard var status = await instance.movies.command(.search([movie.id])) else { return } + status.subject = movie.title + Commands.shared.track(status) + dependencies.toast.show(.movieSearchQueued) Telemetry.record(.movieSearchDispatched) diff --git a/Ruddarr/Views/Series/EpisodeContextMenu.swift b/Ruddarr/Views/Series/EpisodeContextMenu.swift index bc6a9482..3277b263 100644 --- a/Ruddarr/Views/Series/EpisodeContextMenu.swift +++ b/Ruddarr/Views/Series/EpisodeContextMenu.swift @@ -3,6 +3,7 @@ import TelemetryDeck struct EpisodeContextMenu: View { var episode: Episode + var seriesTitle: String? @Environment(SonarrInstance.self) var instance var body: some View { @@ -28,10 +29,14 @@ struct EpisodeContextMenu: View { } func dispatchSearch() async { - guard await instance.series.command(.episodeSearch([episode.id])) else { + guard var status = await instance.series.command(.episodeSearch([episode.id])) else { return } + let title = seriesTitle ?? episode.series?.title + status.subject = title != nil ? "\(title!) — \(episode.episodeLabel)" : episode.episodeLabel + Commands.shared.track(status) + dependencies.toast.show(.episodeSearchQueued) Telemetry.record(.episodeSearchDispatched) diff --git a/Ruddarr/Views/Series/EpisodeView.swift b/Ruddarr/Views/Series/EpisodeView.swift index 2b0f05c1..5787c886 100644 --- a/Ruddarr/Views/Series/EpisodeView.swift +++ b/Ruddarr/Views/Series/EpisodeView.swift @@ -178,7 +178,7 @@ struct EpisodeView: View { ToolbarItem(placement: .primaryAction) { Menu { Section { - EpisodeContextMenu(episode: episode) + EpisodeContextMenu(episode: episode, seriesTitle: series.title) } if episodeFile != nil { @@ -330,11 +330,14 @@ extension EpisodeView { defer { dispatchingSearch = false } dispatchingSearch = true - guard await instance.series.command( + guard var status = await instance.series.command( .episodeSearch([episode.id])) else { return } + status.subject = "\(series.title) — \(episode.episodeLabel)" + Commands.shared.track(status) + dependencies.toast.show(.episodeSearchQueued) Telemetry.record(.episodeSearchDispatched) diff --git a/Ruddarr/Views/Series/SeasonView.swift b/Ruddarr/Views/Series/SeasonView.swift index 0da57d1d..b66cad01 100644 --- a/Ruddarr/Views/Series/SeasonView.swift +++ b/Ruddarr/Views/Series/SeasonView.swift @@ -274,12 +274,15 @@ extension SeasonView { defer { dispatchingSearch = false } dispatchingSearch = true - guard await instance.series.command( + guard var status = await instance.series.command( .seasonSearch(series.id, season: season.id) ) else { return } + status.subject = "\(series.title) — \(String(localized: "Season \(season.id)", comment: "Season number for command subject"))" + Commands.shared.track(status) + dependencies.toast.show(.seasonSearchQueued) Telemetry.record(.seasonSearchDispatched) diff --git a/Ruddarr/Views/Series/SeriesContextMenu.swift b/Ruddarr/Views/Series/SeriesContextMenu.swift index 4939eff4..fcfc3cc8 100644 --- a/Ruddarr/Views/Series/SeriesContextMenu.swift +++ b/Ruddarr/Views/Series/SeriesContextMenu.swift @@ -21,10 +21,13 @@ struct SeriesContextMenu: View { } func dispatchSearch() async { - guard await instance.series.command(.seriesSearch(series.id)) else { + guard var status = await instance.series.command(.seriesSearch(series.id)) else { return } + status.subject = series.title + Commands.shared.track(status) + dependencies.toast.show(.monitoredSearchQueued) Telemetry.record(.seriesSearchDispatched) diff --git a/Ruddarr/Views/Series/SeriesDetails.swift b/Ruddarr/Views/Series/SeriesDetails.swift index 92884064..5e2b684d 100644 --- a/Ruddarr/Views/Series/SeriesDetails.swift +++ b/Ruddarr/Views/Series/SeriesDetails.swift @@ -186,12 +186,15 @@ struct SeriesDetails: View { defer { dispatchingSearch = false } dispatchingSearch = true - guard await instance.series.command( + guard var status = await instance.series.command( .seriesSearch(series.id) ) else { return } + status.subject = series.title + Commands.shared.track(status) + dependencies.toast.show(.monitoredSearchQueued) Telemetry.record(.seriesSearchDispatched) diff --git a/Ruddarr/Views/Series/SeriesItemView.swift b/Ruddarr/Views/Series/SeriesItemView.swift index e7d0393e..dab6d6c8 100644 --- a/Ruddarr/Views/Series/SeriesItemView.swift +++ b/Ruddarr/Views/Series/SeriesItemView.swift @@ -160,7 +160,7 @@ extension SeriesDetailView { } func refresh() async { - guard await instance.series.command(.refreshSeries(series.id)) else { + guard await instance.series.command(.refreshSeries(series.id)) != nil else { return } @@ -172,12 +172,15 @@ extension SeriesDetailView { } func dispatchSearch() async { - guard await instance.series.command( + guard var status = await instance.series.command( .seriesSearch(series.id) ) else { return } + status.subject = series.title + Commands.shared.track(status) + dependencies.toast.show(.monitoredSearchQueued) Telemetry.record(.seriesSearchDispatched)