Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3301ee7
Add InstanceCommandStatus model for Activity tasks
joptimus Apr 5, 2026
875a8d7
Return command status from API layer
joptimus Apr 5, 2026
10c3e4f
Return command status from Movies and SeriesModel
joptimus Apr 5, 2026
efa0b95
Add Commands observable store and merge tests
joptimus Apr 5, 2026
8476a0c
Address Commands review: weak timer capture + safe merge dictionary
joptimus Apr 5, 2026
53545f9
Add Episode.subjectLabel helper for Activity tasks
joptimus Apr 5, 2026
5de7e62
Add CommandListItem row view
joptimus Apr 5, 2026
be18f87
Add CommandSheet detail view
joptimus Apr 5, 2026
3a51b26
Address Activity tasks review: close button, shared formatter, displa…
joptimus Apr 5, 2026
89f9520
Track dispatched commands in Activity tab
joptimus Apr 5, 2026
02bee29
Add CommandsListView and localized strings
joptimus Apr 5, 2026
3c60ec4
Split Activity tab into Downloads and Tasks segments
joptimus Apr 5, 2026
0c8e1df
Register new Activity task files in Xcode project
joptimus Apr 5, 2026
29bb1df
Fix review issues: composite identity, live polling, stale sheet, ref…
joptimus Apr 5, 2026
098f67b
Rename Tasks segment to Searches and show it first
joptimus Apr 5, 2026
4ee7e61
clean up commands code to match existing queue patterns
joptimus Apr 8, 2026
c498d82
Merge remote-tracking branch 'upstream/develop' into feature/activity…
joptimus Apr 8, 2026
5746b67
merged commands into Activity list and fix episode subject labels
joptimus Apr 8, 2026
9189583
remove localization file changes
joptimus Apr 8, 2026
4c3a4d0
apply instance filter to commands section
joptimus Apr 8, 2026
5a2a7f7
set 5 minute time to live after completed
joptimus Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Ruddarr.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
1AFC24FC2F82B789009FF858 /* CommandListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandListItem.swift; sourceTree = "<group>"; };
1AFC24FD2F82B789009FF858 /* CommandSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandSheet.swift; sourceTree = "<group>"; };
2B949CE42CC92C970088B1A8 /* sonarr-history.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "sonarr-history.json"; sourceTree = "<group>"; };
2B949CE62CC92F320088B1A8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; };
2B949CEA2CCBC8600088B1A8 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -918,6 +926,7 @@
BBF477A62B4F8AA300C2DED3 /* Models */ = {
isa = PBXGroup;
children = (
1AFC24F82F82B753009FF858 /* Commands.swift */,
BB2370DC2DCE76A600261710 /* Queue */,
BBF583C02BACA40D00AFA7FB /* Movies */,
BB507D202BD9702B00EC4016 /* Series */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
21 changes: 20 additions & 1 deletion Ruddarr/Dependencies/API/API+Live.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
40 changes: 36 additions & 4 deletions Ruddarr/Dependencies/API/API+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
4 changes: 3 additions & 1 deletion Ruddarr/Dependencies/API/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
216 changes: 216 additions & 0 deletions Ruddarr/Models/Commands/Commands.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
"MoviesSearch",
"SeriesSearch",
"SeasonSearch",
"EpisodeSearch",
]

static let visibleCommandNames: Set<String> = [
"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 }
}
}
23 changes: 21 additions & 2 deletions Ruddarr/Models/Movies/Movies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading