Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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 */; };
Expand Down Expand Up @@ -273,6 +276,9 @@
798010BC2B6268D200BBC056 /* RawRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawRepresentable.swift; sourceTree = "<group>"; };
798010BE2B6268DE00BBC056 /* MovieSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSort.swift; sourceTree = "<group>"; };
79B761CA2B7115EC001DD30E /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = "<group>"; };
AA0000012F00000000000002 /* CommandItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandItem.swift; sourceTree = "<group>"; };
AA0000012F00000000000004 /* CommandListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandListItem.swift; sourceTree = "<group>"; };
AA0000012F00000000000006 /* commands.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = commands.json; sourceTree = "<group>"; };
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 = "<group>"; };
BB01B1F32B6465730025FAF4 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -560,6 +566,7 @@
children = (
BBD5D8112C014538008E3B3F /* Queue.swift */,
BB2370E12DCE76F500261710 /* QueueItem.swift */,
AA0000012F00000000000002 /* CommandItem.swift */,
BB2370E32DCE76F600261710 /* ImportableFile.swift */,
);
path = Queue;
Expand Down Expand Up @@ -779,6 +786,7 @@
BBDBBC682C13A0600087C844 /* QueueSort.swift */,
BB50F2262C3B06AD005E14CA /* QueueListItem.swift */,
BBB8AED32C10DA1A00AA2D9C /* QueueItemSheet.swift */,
AA0000012F00000000000004 /* CommandListItem.swift */,
BB3EBC002CC052B700141868 /* TaskRemovalView.swift */,
BB42B7CA2DCD26AB00DA73A3 /* TaskImportView.swift */,
);
Expand Down Expand Up @@ -912,6 +920,7 @@
BB2370D92DCE657800261710 /* sonarr-manual-import.json */,
BBA06B582F47D4B600A5F9B4 /* popular-movies.json */,
BB181D6E2F48E8DF00981037 /* popular-series.json */,
AA0000012F00000000000006 /* commands.json */,
);
path = "Preview Content";
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
7 changes: 7 additions & 0 deletions Ruddarr/Dependencies/API/API+Live.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions Ruddarr/Dependencies/API/API+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
1 change: 1 addition & 0 deletions Ruddarr/Dependencies/API/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions Ruddarr/Models/Queue/CommandItem.swift
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions Ruddarr/Models/Queue/Queue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 }
Expand Down
69 changes: 69 additions & 0 deletions Ruddarr/Preview Content/commands.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
33 changes: 33 additions & 0 deletions Ruddarr/Views/Activity/CommandListItem.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
40 changes: 36 additions & 4 deletions Ruddarr/Views/ActivityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,15 +47,15 @@ struct ActivityView: View {
.listRowBackground(Color.card)
#endif
} header: {
if !items.isEmpty { sectionHeader }
if !items.isEmpty { queueSectionHeader }
}
}
#if os(iOS)
.background(.systemBackground)
#endif
.scrollContentBackground(.hidden)
.overlay {
if items.isEmpty {
if items.isEmpty && activeCommands.isEmpty {
queueEmpty
}
}
Expand Down Expand Up @@ -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",
Expand All @@ -89,7 +121,7 @@ struct ActivityView: View {
)
}

var sectionHeader: some View {
var queueSectionHeader: some View {
HStack(spacing: 6) {
Text("\(items.count) Task")

Expand Down
Loading