aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTommaso Negri <me@tommasongr.com>2026-08-31 21:56:09 +0200
committerTommaso Negri <me@tommasongr.com>2026-08-31 21:56:09 +0200
commitf8b59230794a6d1c22a48d06388a72b70bc6380f (patch)
tree0d65005989d70e6810688da0c0a0f95dbfed7a28
parent3615193ae44bf4afbc315e5e97d8f3d909e7c16b (diff)
downloadaria-f8b59230794a6d1c22a48d06388a72b70bc6380f.tar.gz
aria-f8b59230794a6d1c22a48d06388a72b70bc6380f.zip
make the thing
-rw-r--r--Makefile26
-rw-r--r--Package.resolved51
-rw-r--r--Package.swift40
-rw-r--r--Sources/aria/Commands/Add+Torrent.swift37
-rw-r--r--Sources/aria/Commands/Add+URI.swift37
-rw-r--r--Sources/aria/Commands/Add.swift12
-rw-r--r--Sources/aria/Commands/List.swift92
-rw-r--r--Sources/aria/Commands/Pause.swift39
-rw-r--r--Sources/aria/Commands/Purge.swift24
-rw-r--r--Sources/aria/Commands/Remove.swift29
-rw-r--r--Sources/aria/Commands/Resume.swift39
-rw-r--r--Sources/aria/Config.swift15
-rw-r--r--Sources/aria/Extensions/KeyedDecodingContainer+Extensions.swift72
-rw-r--r--Sources/aria/Extensions/TableData+Extensions.swift3
-rw-r--r--Sources/aria/Models/Aria2+Params.swift122
-rw-r--r--Sources/aria/Models/Aria2+Values.swift115
-rw-r--r--Sources/aria/Models/Aria2.swift86
-rw-r--r--Sources/aria/Models/JSONRPC.swift62
-rw-r--r--Sources/aria/aria.swift34
-rw-r--r--Tests/ariaTests/ariaTests.swift30
20 files changed, 931 insertions, 34 deletions
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6335f0e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,26 @@
+PREFIX ?= $(HOME)/.local
+
+.PHONY: build
+build: .build/release/aria .build/plugins/GenerateManual/outputs/aria/aria.1
+
+.build/release/aria:
+ swift build -c release
+
+.build/plugins/GenerateManual/outputs/aria/aria.1:
+ swift package generate-manual
+
+.PHONY: clean
+clean:
+ @rm -rf .build
+
+.PHONY: install
+install: build
+ @install -d $(PREFIX)/bin/
+ @install -Dm755 .build/release/aria $(PREFIX)/bin/
+ @install -d $(PREFIX)/share/man/man1/
+ @install -Dm644 .build/plugins/GenerateManual/outputs/aria/aria.1 $(PREFIX)/share/man/man1/
+
+.PHONY: uninstall
+uninstall:
+ @rm -f $(PREFIX)/bin/aria
+ @rm -f $(PREFIX)/share/man/man1/aria.1
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..c115602
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,51 @@
+{
+ "originHash" : "9f9e8184b879efd7c4ba6a8c50c6ca2b0a9b4487ab0e36961c895f3b91c2ced5",
+ "pins" : [
+ {
+ "identity" : "noora",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tuist/Noora/",
+ "state" : {
+ "revision" : "b01663496be276c7e8b198486febedc4b3689c2f",
+ "version" : "0.57.0"
+ }
+ },
+ {
+ "identity" : "path",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tuist/path",
+ "state" : {
+ "revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb",
+ "version" : "0.3.8"
+ }
+ },
+ {
+ "identity" : "rainbow",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/onevcat/Rainbow",
+ "state" : {
+ "revision" : "cdf146ae671b2624917648b61c908d1244b98ca1",
+ "version" : "4.2.1"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
+ "state" : {
+ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
+ "version" : "1.8.2"
+ }
+ },
+ {
+ "identity" : "swift-log",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-log",
+ "state" : {
+ "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222",
+ "version" : "1.15.0"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index ae8edd6..ea8942c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,26 +1,26 @@
// swift-tools-version: 6.3
-// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
- name: "aria",
- dependencies: [
- .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
- ],
- targets: [
- // Targets are the basic building blocks of a package, defining a module or a test suite.
- // Targets can depend on other targets in this package and products from dependencies.
- .executableTarget(
- name: "aria",
- dependencies: [
- .product(name: "ArgumentParser", package: "swift-argument-parser"),
- ]
- ),
- .testTarget(
- name: "ariaTests",
- dependencies: ["aria"]
- ),
- ],
- swiftLanguageModes: [.v6]
+ name: "aria",
+ platforms: [.macOS(.v26)],
+ dependencies: [
+ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
+ .package(url: "https://github.com/tuist/Noora/", from: "0.56.0"),
+ ],
+ targets: [
+ .executableTarget(
+ name: "aria",
+ dependencies: [
+ .product(name: "ArgumentParser", package: "swift-argument-parser"),
+ .product(name: "Noora", package: "Noora"),
+ ]
+ ),
+ .testTarget(
+ name: "ariaTests",
+ dependencies: ["aria"]
+ ),
+ ],
+ swiftLanguageModes: [.v6]
)
diff --git a/Sources/aria/Commands/Add+Torrent.swift b/Sources/aria/Commands/Add+Torrent.swift
new file mode 100644
index 0000000..4d0375e
--- /dev/null
+++ b/Sources/aria/Commands/Add+Torrent.swift
@@ -0,0 +1,37 @@
+import ArgumentParser
+import Foundation
+import Noora
+
+extension Aria.Add {
+ struct Torrent: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "torrent",
+ abstract: "Add download from .torrent file."
+ )
+
+ @Option(name: .shortAndLong, help: "Define the directory to save files.")
+ var directory: String = Config.directory
+
+ @Flag(name: .long, help: "Enable seeding after download.")
+ var seed: Bool = false
+
+ @Argument(help: "URI of .torrent file to download.")
+ var uri: String
+
+ func run() async throws {
+ let noora = Noora()
+
+ var options = [String : String]()
+ options.updateValue((directory as NSString).expandingTildeInPath, forKey: "dir")
+ if seed == false { options.updateValue("0", forKey: "seed-time") }
+
+ do {
+ let gid = try await Aria2.addTorrent(uri: uri, options: options)
+ noora.success("\(gid)")
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ }
+ }
+}
diff --git a/Sources/aria/Commands/Add+URI.swift b/Sources/aria/Commands/Add+URI.swift
new file mode 100644
index 0000000..bc3b523
--- /dev/null
+++ b/Sources/aria/Commands/Add+URI.swift
@@ -0,0 +1,37 @@
+import ArgumentParser
+import Foundation
+import Noora
+
+extension Aria.Add {
+ struct URI: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "uri",
+ abstract: "Add downloads from URIs.",
+ )
+
+ @Option(name: .shortAndLong, help: "Define the directory to save files.")
+ var directory: String = Config.directory
+
+ @Flag(name: .long, help: "Enable seeding after download.")
+ var seed: Bool = false
+
+ @Argument(help: "URIs of resources to download.")
+ var uris: [String]
+
+ func run() async throws {
+ let noora = Noora()
+
+ var options = [String: String]()
+ options.updateValue((directory as NSString).expandingTildeInPath, forKey: "dir")
+ if seed == false { options.updateValue("0", forKey: "seed-time") }
+
+ do {
+ let gid = try await Aria2.addUri(uris: uris, options: options)
+ noora.success("\(gid)")
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ }
+ }
+}
diff --git a/Sources/aria/Commands/Add.swift b/Sources/aria/Commands/Add.swift
new file mode 100644
index 0000000..c10bd01
--- /dev/null
+++ b/Sources/aria/Commands/Add.swift
@@ -0,0 +1,12 @@
+import ArgumentParser
+
+extension Aria {
+ struct Add: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "add",
+ abstract: "Add downloads.",
+ subcommands: [URI.self, Torrent.self],
+ defaultSubcommand: URI.self,
+ )
+ }
+}
diff --git a/Sources/aria/Commands/List.swift b/Sources/aria/Commands/List.swift
new file mode 100644
index 0000000..a5be2a3
--- /dev/null
+++ b/Sources/aria/Commands/List.swift
@@ -0,0 +1,92 @@
+import ArgumentParser
+import Foundation
+import Noora
+
+extension Aria {
+ struct List: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "list",
+ abstract: "List downloads.",
+ aliases: ["ls"]
+ )
+
+ mutating func run() async throws {
+ let noora = Noora()
+
+ do {
+ let initial = try await Self.makeSnapshot()
+ let latestGids = LatestGids(Self.gids(from: initial))
+
+ let updates = AsyncStream<TableData> { continuation in
+ let producer = Task {
+ while !Task.isCancelled {
+ if let snapshot = try? await Self.makeSnapshot() {
+ await latestGids.set(Self.gids(from: snapshot))
+ continuation.yield(snapshot)
+ }
+ try? await Task.sleep(for: .seconds(1))
+ }
+ continuation.finish()
+ }
+ continuation.onTermination = { _ in
+ producer.cancel()
+ }
+ }
+
+ let selectedIndex = try await noora.selectableTable(
+ initial,
+ updates: updates,
+ pageSize: 8
+ )
+
+ let gids = await latestGids.current()
+ if gids.indices.contains(selectedIndex) {
+ print("Picked download: \(gids[selectedIndex])")
+ } else {
+ print("Picked download at index \(selectedIndex)")
+ }
+ } catch {
+ noora.error(.alert("\(error)"))
+ throw ExitCode.failure
+ }
+ }
+
+ private static func makeColumns() -> [TableColumn] {
+ [
+ TableColumn(title: "Name", width: .flexible(min: 10, max: 50), alignment: .left),
+ TableColumn(title: "Status", width: .auto, alignment: .left),
+ TableColumn(title: "Progress", width: .auto, alignment: .left),
+ TableColumn(title: "Size", width: .auto, alignment: .left),
+ TableColumn(title: "Down", width: .auto, alignment: .left),
+ TableColumn(title: "Up", width: .auto, alignment: .left),
+ TableColumn(title: "ETA", width: .auto, alignment: .left),
+ TableColumn(title: "GID", width: .auto, alignment: .left),
+ ]
+ }
+
+ private static func makeSnapshot() async throws -> TableData {
+ let active = try await Aria2.tellAll()
+ return TableData(columns: makeColumns(), rows: active.map(\.tabularized))
+ }
+
+ private static func gids(from data: TableData) -> [String] {
+ data.rows.map { $0.first?.plain() ?? "-" }
+ }
+ }
+}
+
+private actor LatestGids {
+ private var gids: [String]
+
+ init(_ gids: [String]) {
+ self.gids = gids
+ }
+
+ func set(_ gids: [String]) {
+ self.gids = gids
+ }
+
+ func current() -> [String] {
+ gids
+ }
+}
diff --git a/Sources/aria/Commands/Pause.swift b/Sources/aria/Commands/Pause.swift
new file mode 100644
index 0000000..ac7cd85
--- /dev/null
+++ b/Sources/aria/Commands/Pause.swift
@@ -0,0 +1,39 @@
+import ArgumentParser
+import Noora
+
+extension Aria {
+ struct Pause: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "pause",
+ abstract: "Pause downloads.",
+ aliases: ["stop"]
+ )
+
+ @Argument(help: .init("The GIDs of the downloads to pause."))
+ var gids: [String] = []
+
+ func run() async throws {
+ let noora = Noora()
+
+ if gids.isEmpty {
+ do {
+ let status = try await Aria2.pauseAll()
+ noora.success("\(status)")
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ } else {
+ do {
+ for gid in gids {
+ let status = try await Aria2.pause(gid: gid)
+ noora.success("\(status)")
+ }
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/aria/Commands/Purge.swift b/Sources/aria/Commands/Purge.swift
new file mode 100644
index 0000000..95eeb40
--- /dev/null
+++ b/Sources/aria/Commands/Purge.swift
@@ -0,0 +1,24 @@
+import ArgumentParser
+import Noora
+
+extension Aria {
+ struct Purge: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "purge",
+ abstract: "Purge completed, removed, and failed downloads.",
+ aliases: ["clean"]
+ )
+
+ func run() async throws {
+ let noora = Noora()
+
+ do {
+ let status = try await Aria2.purgeDownloadResult()
+ noora.success("\(status)")
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ }
+ }
+}
diff --git a/Sources/aria/Commands/Remove.swift b/Sources/aria/Commands/Remove.swift
new file mode 100644
index 0000000..5b67794
--- /dev/null
+++ b/Sources/aria/Commands/Remove.swift
@@ -0,0 +1,29 @@
+import ArgumentParser
+import Noora
+
+extension Aria {
+ struct Remove: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "remove",
+ abstract: "Remove downloads.",
+ aliases: ["rm", "cancel"]
+ )
+
+ @Argument(help: .init("The GIDs of the downloads to remove."))
+ var gids: [String]
+
+ func run() async throws {
+ let noora = Noora()
+
+ do {
+ for gid in gids {
+ let status = try await Aria2.remove(gid: gid)
+ noora.success("\(status)")
+ }
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ }
+ }
+}
diff --git a/Sources/aria/Commands/Resume.swift b/Sources/aria/Commands/Resume.swift
new file mode 100644
index 0000000..d2e49f1
--- /dev/null
+++ b/Sources/aria/Commands/Resume.swift
@@ -0,0 +1,39 @@
+import ArgumentParser
+import Noora
+
+extension Aria {
+ struct Resume: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "resume",
+ abstract: "Resume downloads.",
+ aliases: ["play"]
+ )
+
+ @Argument(help: .init("The GIDs of the downloads to resume."))
+ var gids: [String] = []
+
+ func run() async throws {
+ let noora = Noora()
+
+ if gids.isEmpty {
+ do {
+ let status = try await Aria2.unpauseAll()
+ noora.success("\(status)")
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ } else {
+ do {
+ for gid in gids {
+ let status = try await Aria2.unpause(gid: gid)
+ noora.success("\(status)")
+ }
+ } catch {
+ noora.error("\(error.localizedDescription)")
+ throw ExitCode.failure
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/aria/Config.swift b/Sources/aria/Config.swift
new file mode 100644
index 0000000..cc65b5f
--- /dev/null
+++ b/Sources/aria/Config.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+enum Config {
+ static let host: String = "127.0.0.1"
+
+ static let port: Int = 6800
+
+ static let secret: String = "my_secret"
+
+ static var endpoint: URL {
+ URL(string: "http://\(host):\(port)/jsonrpc")!
+ }
+
+ static let directory: String = "~/Downloads/"
+}
diff --git a/Sources/aria/Extensions/KeyedDecodingContainer+Extensions.swift b/Sources/aria/Extensions/KeyedDecodingContainer+Extensions.swift
new file mode 100644
index 0000000..ee0810f
--- /dev/null
+++ b/Sources/aria/Extensions/KeyedDecodingContainer+Extensions.swift
@@ -0,0 +1,72 @@
+import Foundation
+
+extension KeyedDecodingContainer {
+ /// aria2 sends numeric fields as strings; accept either.
+ func decodeLenientInt64(forKey key: K) throws -> Int64 {
+ if let value = try? decode(Int64.self, forKey: key) {
+ return value
+ }
+ let string = try decode(String.self, forKey: key)
+ guard let value = Int64(string) else {
+ throw DecodingError.typeMismatch(
+ Int.self,
+ DecodingError.Context(
+ codingPath: codingPath + [key],
+ debugDescription: "Expected Int or numeric string, got \(string)"
+ )
+ )
+ }
+ return value
+ }
+
+ func decodeLenientInt64IfPresent(forKey key: K) throws -> Int64? {
+ guard contains(key), try decodeNil(forKey: key) == false else { return nil }
+ return try decodeLenientInt64(forKey: key)
+ }
+
+ /// aria2 sends numeric fields as strings; accept either.
+ func decodeLenientInt(forKey key: K) throws -> Int {
+ if let value = try? decode(Int.self, forKey: key) {
+ return value
+ }
+ let string = try decode(String.self, forKey: key)
+ guard let value = Int(string) else {
+ throw DecodingError.typeMismatch(
+ Int.self,
+ DecodingError.Context(
+ codingPath: codingPath + [key],
+ debugDescription: "Expected Int or numeric string, got \(string)"
+ )
+ )
+ }
+ return value
+ }
+
+ func decodeLenientIntIfPresent(forKey key: K) throws -> Int? {
+ guard contains(key), try decodeNil(forKey: key) == false else { return nil }
+ return try decodeLenientInt(forKey: key)
+ }
+
+ /// aria2 sends booleans as "true"/"false" strings; accept either.
+ func decodeLenientBool(forKey key: K) throws -> Bool {
+ if let value = try? decode(Bool.self, forKey: key) {
+ return value
+ }
+ let string = try decode(String.self, forKey: key)
+ guard let value = Bool(string) else {
+ throw DecodingError.typeMismatch(
+ Bool.self,
+ DecodingError.Context(
+ codingPath: codingPath + [key],
+ debugDescription: "Expected Bool or boolean string, got \(string)"
+ )
+ )
+ }
+ return value
+ }
+
+ func decodeLenientBoolIfPresent(forKey key: K) throws -> Bool? {
+ guard contains(key), try decodeNil(forKey: key) == false else { return nil }
+ return try decodeLenientBool(forKey: key)
+ }
+}
diff --git a/Sources/aria/Extensions/TableData+Extensions.swift b/Sources/aria/Extensions/TableData+Extensions.swift
new file mode 100644
index 0000000..be29504
--- /dev/null
+++ b/Sources/aria/Extensions/TableData+Extensions.swift
@@ -0,0 +1,3 @@
+import Noora
+
+extension TableData: @retroactive @unchecked Sendable {}
diff --git a/Sources/aria/Models/Aria2+Params.swift b/Sources/aria/Models/Aria2+Params.swift
new file mode 100644
index 0000000..9a924ad
--- /dev/null
+++ b/Sources/aria/Models/Aria2+Params.swift
@@ -0,0 +1,122 @@
+extension Aria2 {
+ struct AddUriParams: Encodable {
+ let secret: String = Config.secret
+ let uris: [String]
+ let options: [String: String]
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ try container.encode(uris)
+ if !options.isEmpty { try container.encode(options) }
+ }
+ }
+
+ struct AddTorrentParams: Encodable {
+ let secret: String = Config.secret
+ let uri: String
+ let options: [String: String]
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ try container.encode(uri)
+ if !options.isEmpty { try container.encode(options) }
+ }
+ }
+
+ struct TellActiveParams: Encodable {
+ let secret: String = Config.secret
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ }
+ }
+
+ struct TellWaitingParams: Encodable {
+ let secret: String = Config.secret
+ let offset: Int
+ let num: Int
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ try container.encode(offset)
+ try container.encode(num)
+ }
+ }
+
+ struct TellStoppedParams: Encodable {
+ let secret: String = Config.secret
+ let offset: Int
+ let num: Int
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ try container.encode(offset)
+ try container.encode(num)
+ }
+ }
+
+ struct PauseParams: Encodable {
+ let secret: String = Config.secret
+ let gid: String
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ try container.encode(gid)
+ }
+ }
+
+ struct PauseAllParams: Encodable {
+ let secret: String = Config.secret
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ }
+ }
+
+ struct UnpauseParams: Encodable {
+ let secret: String = Config.secret
+ let gid: String
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ try container.encode(gid)
+ }
+ }
+
+ struct UnpauseAllParams: Encodable {
+ let secret: String = Config.secret
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ }
+ }
+
+ struct RemoveParams: Encodable {
+ let secret: String = Config.secret
+ let gid: String
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ try container.encode(gid)
+ }
+ }
+
+ struct PurgeDownloadResultParams: Encodable {
+ let secret: String = Config.secret
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode("token:\(secret)")
+ }
+ }
+}
diff --git a/Sources/aria/Models/Aria2+Values.swift b/Sources/aria/Models/Aria2+Values.swift
new file mode 100644
index 0000000..8f62d70
--- /dev/null
+++ b/Sources/aria/Models/Aria2+Values.swift
@@ -0,0 +1,115 @@
+import Foundation
+import Noora
+
+extension Aria2 {
+ struct TellStatusValue: Decodable {
+ let gid: String
+ let status: Status
+ let totalLength: Int64
+ let completedLength: Int64
+ let uploadLength: Int64
+ let downloadSpeed: Int64
+ let uploadSpeed: Int64
+ let numSeeders: Int?
+ let seeder: Bool?
+ // [more]
+ let dir: URL
+ let files: [File]
+ // [more]
+ let bittorrent: BitTorrent?
+
+ /// The download completion progress (e.g. `80.5%`).
+ var progress: String {
+ let fraction = totalLength > 0 ? Double(completedLength) / Double(totalLength) : 0
+ return fraction.formatted(.percent.precision(.fractionLength(2)))
+ }
+
+ /// The estimated remaining time (e.g. `14hr 10min`), or `n/a` when it cannot be computed.
+ var ETA: String {
+ guard downloadSpeed > 0, completedLength < totalLength else { return "n/a" }
+ let seconds = Double(totalLength - completedLength) / Double(downloadSpeed)
+ return Self.etaFormatter.string(from: seconds) ?? "n/a"
+ }
+
+ private static let etaFormatter: DateComponentsFormatter = {
+ let formatter = DateComponentsFormatter()
+ formatter.unitsStyle = .abbreviated
+ formatter.allowedUnits = [.day, .hour, .minute, .second]
+ formatter.collapsesLargestUnit = true
+ return formatter
+ }()
+
+ var name: String {
+ if let name = bittorrent?.info?.name {
+ return name
+ } else if let name = files.first?.path, let url = URL(string: name) {
+ return url.lastPathComponent
+ } else if let name = files.first?.path {
+ return name
+ } else {
+ return "-"
+ }
+ }
+
+ var tabularized: TableRow {
+ let byteCountFormatter = ByteCountFormatter()
+
+ return [
+ name,
+ status.rawValue,
+ progress,
+ byteCountFormatter.string(fromByteCount: totalLength),
+ byteCountFormatter.string(fromByteCount: downloadSpeed),
+ byteCountFormatter.string(fromByteCount: uploadSpeed),
+ ETA,
+ gid
+ ].map(TerminalText.init)
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case gid, status, totalLength, completedLength, uploadLength
+ case downloadSpeed, uploadSpeed, numSeeders, seeder, dir, files, bittorrent
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ gid = try container.decode(String.self, forKey: .gid)
+ status = try container.decode(Status.self, forKey: .status)
+ totalLength = try container.decodeLenientInt64(forKey: .totalLength)
+ completedLength = try container.decodeLenientInt64(forKey: .completedLength)
+ uploadLength = try container.decodeLenientInt64(forKey: .uploadLength)
+ downloadSpeed = try container.decodeLenientInt64(forKey: .downloadSpeed)
+ uploadSpeed = try container.decodeLenientInt64(forKey: .uploadSpeed)
+ numSeeders = try container.decodeLenientIntIfPresent(forKey: .numSeeders)
+ seeder = try container.decodeLenientBoolIfPresent(forKey: .seeder)
+ dir = try container.decode(URL.self, forKey: .dir)
+ files = try container.decode([File].self, forKey: .files)
+ if container.contains(.bittorrent) {
+ bittorrent = try container.decode(BitTorrent.self, forKey: .bittorrent)
+ } else {
+ bittorrent = nil
+ }
+ }
+
+ enum Status: String, Decodable {
+ case active
+ case waiting
+ case paused
+ case error
+ case complete
+ case removed
+ }
+
+ struct BitTorrent: Decodable {
+ let info: Info?
+
+ struct Info: Decodable {
+ let name: String
+ }
+ }
+
+ struct File: Decodable {
+ let path: String
+ }
+ }
+}
diff --git a/Sources/aria/Models/Aria2.swift b/Sources/aria/Models/Aria2.swift
new file mode 100644
index 0000000..aa75d9b
--- /dev/null
+++ b/Sources/aria/Models/Aria2.swift
@@ -0,0 +1,86 @@
+enum Aria2 {
+ static func addUri(uris: [String], options: [String: String] = [:]) async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.addUri",
+ params: AddUriParams(uris: uris, options: options)
+ )
+ }
+
+ static func addTorrent(uri: String, options: [String: String] = [:]) async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.addTorrent",
+ params: AddTorrentParams(uri: uri, options: options)
+ )
+ }
+
+ static func tellActive() async throws -> [TellStatusValue] {
+ try await JSONRPC.call(
+ method: "aria2.tellActive",
+ params: TellActiveParams()
+ )
+ }
+
+ static func tellWaiting(offset: Int = 0, num: Int = .max) async throws -> [TellStatusValue] {
+ try await JSONRPC.call(
+ method: "aria2.tellWaiting",
+ params: TellWaitingParams(offset: offset, num: num)
+ )
+ }
+
+ static func tellStopped(offset: Int = 0, num: Int = .max) async throws -> [TellStatusValue] {
+ try await JSONRPC.call(
+ method: "aria2.tellStopped",
+ params: TellStoppedParams(offset: offset, num: num)
+ )
+ }
+
+ static func tellAll() async throws -> [TellStatusValue] {
+ let maxNum = 999_999
+ var downloads = try await tellActive()
+ downloads += try await tellWaiting(num: maxNum)
+ downloads += try await tellStopped(num: maxNum)
+ return downloads
+ }
+
+ static func pause(gid: String) async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.pause",
+ params: PauseParams(gid: gid)
+ )
+ }
+
+ static func pauseAll() async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.pauseAll",
+ params: PauseAllParams()
+ )
+ }
+
+ static func unpause(gid: String) async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.unpause",
+ params: UnpauseParams(gid: gid)
+ )
+ }
+
+ static func unpauseAll() async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.unpauseAll",
+ params: UnpauseAllParams()
+ )
+ }
+
+ static func remove(gid: String) async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.remove",
+ params: RemoveParams(gid: gid)
+ )
+ }
+
+ static func purgeDownloadResult() async throws -> String {
+ try await JSONRPC.call(
+ method: "aria2.purgeDownloadResult",
+ params: PurgeDownloadResultParams()
+ )
+ }
+}
diff --git a/Sources/aria/Models/JSONRPC.swift b/Sources/aria/Models/JSONRPC.swift
new file mode 100644
index 0000000..98d2bc9
--- /dev/null
+++ b/Sources/aria/Models/JSONRPC.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+struct JSONRPCRequest<Params: Encodable>: Encodable {
+ let jsonrpc = "2.0"
+ let id = "aria"
+ let method: String
+ let params: Params
+}
+
+struct JSONRPCResponse<Value: Decodable>: Decodable {
+ let result: Value?
+ let error: JSONRPCError?
+}
+
+struct JSONRPCError: Decodable, Error, LocalizedError {
+ let code: Int
+ let message: String
+
+ var errorDescription: String? { "aria2 RPC error \(code): \(message)" }
+}
+
+enum JSONRPCClientError: Error, LocalizedError {
+ case unexpectedHTTPStatus(Int)
+ case missingResult
+
+ var errorDescription: String? {
+ switch self {
+ case let .unexpectedHTTPStatus(status):
+ "aria2 RPC endpoint returned HTTP \(status)"
+ case .missingResult:
+ "aria2 RPC response contained neither a result nor an error"
+ }
+ }
+}
+
+enum JSONRPC {
+ static func call<Params: Encodable, Value: Decodable>(
+ method: String,
+ params: Params
+ ) async throws -> Value {
+ var request = URLRequest(url: Config.endpoint)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(JSONRPCRequest(method: method, params: params))
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ if let httpResponse = response as? HTTPURLResponse,
+ !(200..<300).contains(httpResponse.statusCode)
+ {
+ throw JSONRPCClientError.unexpectedHTTPStatus(httpResponse.statusCode)
+ }
+
+ let decoded = try JSONDecoder().decode(JSONRPCResponse<Value>.self, from: data)
+ if let error = decoded.error {
+ throw error
+ }
+ guard let result = decoded.result else {
+ throw JSONRPCClientError.missingResult
+ }
+ return result
+ }
+}
diff --git a/Sources/aria/aria.swift b/Sources/aria/aria.swift
index 6c7ef45..ef06bcc 100644
--- a/Sources/aria/aria.swift
+++ b/Sources/aria/aria.swift
@@ -1,14 +1,28 @@
-// The Swift Programming Language
-// https://docs.swift.org/swift-book
-//
-// Swift Argument Parser
-// https://swiftpackageindex.com/apple/swift-argument-parser/documentation
-
import ArgumentParser
@main
-struct aria: ParsableCommand {
- mutating func run() throws {
- print("Hello, world!")
- }
+struct Aria: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "aria",
+ abstract: "Friendly aria2 wrapper for your personal downloads.",
+ discussion: """
+ aria helps you keep an eye on and manage all your aria2 downloads.
+ The cli wraps the standard aria2c JSONRPC server, exposing just
+ enough functionalities to be dangerous.
+
+ Enjoy a unified API to add, pause, resume and remove downloads.
+ Use the list command to check the current status in a beautifully
+ formatted table.
+ """,
+ version: "1.0.0",
+ subcommands: [
+ List.self,
+ Add.self,
+ Pause.self,
+ Resume.self,
+ Remove.self,
+ Purge.self
+ ],
+ defaultSubcommand: List.self
+ )
}
diff --git a/Tests/ariaTests/ariaTests.swift b/Tests/ariaTests/ariaTests.swift
index 230a507..6f8162e 100644
--- a/Tests/ariaTests/ariaTests.swift
+++ b/Tests/ariaTests/ariaTests.swift
@@ -1,8 +1,30 @@
+import Foundation
import Testing
+
@testable import aria
-@Test func example() async throws {
- // Write your test here and use APIs like `#expect(...)` to check expected conditions.
- // Swift Testing Documentation
- // https://developer.apple.com/documentation/testing
+@Test func addUriParamsEncodesPositionalArray() throws {
+ let params = Aria2.AddUriParams(
+ secret: "s3cret",
+ uris: ["https://example.com/file.zip"],
+ options: ["dir": "/tmp"]
+ )
+
+ let data = try JSONEncoder().encode(params)
+ let value = try JSONSerialization.jsonObject(with: data) as! [Any]
+
+ #expect(value.count == 3)
+ #expect(value[0] as? String == "token:s3cret")
+ #expect(value[1] as? [String] == ["https://example.com/file.zip"])
+ #expect(value[2] as? [String: String] == ["dir": "/tmp"])
+}
+
+@Test func addUriParamsOmitsSecretAndOptions() throws {
+ let params = Aria2.AddUriParams(secret: nil, uris: ["magnet:?xt=1"], options: [:])
+
+ let data = try JSONEncoder().encode(params)
+ let value = try JSONSerialization.jsonObject(with: data) as! [Any]
+
+ #expect(value.count == 1)
+ #expect(value[0] as? [String] == ["magnet:?xt=1"])
}