make the thing
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+20
-20
@@ -1,26 +1,26 @@
|
|||||||
// swift-tools-version: 6.3
|
// swift-tools-version: 6.3
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "aria",
|
name: "aria",
|
||||||
dependencies: [
|
platforms: [.macOS(.v26)],
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
|
dependencies: [
|
||||||
],
|
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
|
||||||
targets: [
|
.package(url: "https://github.com/tuist/Noora/", from: "0.56.0"),
|
||||||
// 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.
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "aria",
|
name: "aria",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
]
|
.product(name: "Noora", package: "Noora"),
|
||||||
),
|
]
|
||||||
.testTarget(
|
),
|
||||||
name: "ariaTests",
|
.testTarget(
|
||||||
dependencies: ["aria"]
|
name: "ariaTests",
|
||||||
),
|
dependencies: ["aria"]
|
||||||
],
|
),
|
||||||
swiftLanguageModes: [.v6]
|
],
|
||||||
|
swiftLanguageModes: [.v6]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import Noora
|
||||||
|
|
||||||
|
extension TableData: @retroactive @unchecked Sendable {}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-10
@@ -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
|
import ArgumentParser
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct aria: ParsableCommand {
|
struct Aria: AsyncParsableCommand {
|
||||||
mutating func run() throws {
|
static let configuration = CommandConfiguration(
|
||||||
print("Hello, world!")
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import aria
|
@testable import aria
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func addUriParamsEncodesPositionalArray() throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
let params = Aria2.AddUriParams(
|
||||||
// Swift Testing Documentation
|
secret: "s3cret",
|
||||||
// https://developer.apple.com/documentation/testing
|
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"])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user