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
|
||||
}
|
||||
+3
-3
@@ -1,20 +1,20 @@
|
||||
// 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",
|
||||
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: [
|
||||
// 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"),
|
||||
.product(name: "Noora", package: "Noora"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
|
||||
@@ -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
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user