From f8b59230794a6d1c22a48d06388a72b70bc6380f Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Mon, 31 Aug 2026 21:56:09 +0200 Subject: [PATCH] make the thing --- Makefile | 26 ++++ Package.resolved | 51 ++++++++ Package.swift | 40 +++--- Sources/aria/Commands/Add+Torrent.swift | 37 ++++++ Sources/aria/Commands/Add+URI.swift | 37 ++++++ Sources/aria/Commands/Add.swift | 12 ++ Sources/aria/Commands/List.swift | 92 +++++++++++++ Sources/aria/Commands/Pause.swift | 39 ++++++ Sources/aria/Commands/Purge.swift | 24 ++++ Sources/aria/Commands/Remove.swift | 29 +++++ Sources/aria/Commands/Resume.swift | 39 ++++++ Sources/aria/Config.swift | 15 +++ .../KeyedDecodingContainer+Extensions.swift | 72 +++++++++++ .../Extensions/TableData+Extensions.swift | 3 + Sources/aria/Models/Aria2+Params.swift | 122 ++++++++++++++++++ Sources/aria/Models/Aria2+Values.swift | 115 +++++++++++++++++ Sources/aria/Models/Aria2.swift | 86 ++++++++++++ Sources/aria/Models/JSONRPC.swift | 62 +++++++++ Sources/aria/aria.swift | 34 +++-- Tests/ariaTests/ariaTests.swift | 30 ++++- 20 files changed, 931 insertions(+), 34 deletions(-) create mode 100644 Makefile create mode 100644 Package.resolved create mode 100644 Sources/aria/Commands/Add+Torrent.swift create mode 100644 Sources/aria/Commands/Add+URI.swift create mode 100644 Sources/aria/Commands/Add.swift create mode 100644 Sources/aria/Commands/List.swift create mode 100644 Sources/aria/Commands/Pause.swift create mode 100644 Sources/aria/Commands/Purge.swift create mode 100644 Sources/aria/Commands/Remove.swift create mode 100644 Sources/aria/Commands/Resume.swift create mode 100644 Sources/aria/Config.swift create mode 100644 Sources/aria/Extensions/KeyedDecodingContainer+Extensions.swift create mode 100644 Sources/aria/Extensions/TableData+Extensions.swift create mode 100644 Sources/aria/Models/Aria2+Params.swift create mode 100644 Sources/aria/Models/Aria2+Values.swift create mode 100644 Sources/aria/Models/Aria2.swift create mode 100644 Sources/aria/Models/JSONRPC.swift 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 { 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: Encodable { + let jsonrpc = "2.0" + let id = "aria" + let method: String + let params: Params +} + +struct JSONRPCResponse: 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( + 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.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"]) }