summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Segundo2023-06-11 22:09:15 +0200
committerChristian Segundo2023-06-11 22:09:15 +0200
commit3bd3f432a95da405634cdbd2a662d79a3a5ba7af (patch)
treeed7ac24e3309d8c6b4f43d1051d95b18a7165a2c
downloadzmission-3bd3f432a95da405634cdbd2a662d79a3a5ba7af.tar.gz
wip
-rw-r--r--build.zig47
-rw-r--r--src/main.zig73
-rw-r--r--src/request.zig363
-rw-r--r--src/transmission.zig106
-rw-r--r--src/util.zig14
-rw-r--r--test.c13
-rw-r--r--xmission.h20
7 files changed, 636 insertions, 0 deletions
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..d13c67b
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,47 @@
+const std = @import("std");
+
+// Although this function looks imperative, note that its job is to
+// declaratively construct a build graph that will be executed by an external
+// runner.
+pub fn build(b: *std.Build) void {
+ // Standard target options allows the person running `zig build` to choose
+ // what target to build for. Here we do not override the defaults, which
+ // means any target is allowed, and the default is native. Other options
+ // for restricting supported target set are available.
+ const target = b.standardTargetOptions(.{});
+
+ // Standard optimization options allow the person running `zig build` to select
+ // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
+ // set a preferred release mode, allowing the user to decide how to optimize.
+ const optimize = b.standardOptimizeOption(.{});
+
+ const lib = b.addStaticLibrary(.{
+ .name = "zmission",
+ // In this case the main source file is merely a path, however, in more
+ // complicated build scripts, this could be a generated file.
+ .root_source_file = .{ .path = "src/main.zig" },
+ .target = target,
+ .optimize = optimize,
+ });
+
+ // This declares intent for the library to be installed into the standard
+ // location when the user invokes the "install" step (the default step when
+ // running `zig build`).
+ b.installArtifact(lib);
+
+ // Creates a step for unit testing. This only builds the test executable
+ // but does not run it.
+ const main_tests = b.addTest(.{
+ .root_source_file = .{ .path = "src/main.zig" },
+ .target = target,
+ .optimize = optimize,
+ });
+
+ const run_main_tests = b.addRunArtifact(main_tests);
+
+ // This creates a build step. It will be visible in the `zig build --help` menu,
+ // and can be selected like this: `zig build test`
+ // This will evaluate the `test` step rather than the default, which is "install".
+ const test_step = b.step("test", "Run library tests");
+ test_step.dependOn(&run_main_tests.step);
+}
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 0000000..1b796a3
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,73 @@
+const std = @import("std");
+const testing = std.testing;
+const transmission = @import("transmission.zig");
+
+var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+const allocator = gpa.allocator();
+
+export fn add(a: i32, b: i32) i32 {
+ const clientOptions = transmission.ClientOptions{
+ .host = "192.168.0.2",
+ .port = 9091,
+ .https = false,
+ };
+ var client = transmission.Client.init(allocator, clientOptions);
+ defer client.deinit();
+
+ const body = transmission.sessionGet(&client) catch |err| {
+ std.debug.print("error: {any}\n", .{err});
+ unreachable;
+ };
+ defer allocator.free(body);
+
+ std.debug.print("body: {s}\n", .{body});
+
+ return a + b;
+}
+
+export fn c_client_init(clientOptions: transmission.ClientOptions) ?*anyopaque {
+ var client = allocator.create(transmission.Client) catch unreachable;
+ client.* = transmission.Client.init(allocator, clientOptions);
+ return @ptrCast(*anyopaque, client);
+}
+
+export fn c_client_deinit(client: ?*anyopaque) void {
+ var real_client: *transmission.Client = @ptrCast(*transmission.Client, @alignCast(
+ @alignOf(transmission.Client),
+ client.?,
+ ));
+ real_client.*.deinit();
+ allocator.destroy(real_client);
+}
+
+export fn c_session_get(client: ?*anyopaque, buf: [*]u8) c_int {
+ var real_client: *transmission.Client = @ptrCast(*transmission.Client, @alignCast(
+ @alignOf(transmission.Client),
+ client.?,
+ ));
+
+ const body = transmission.sessionGet(real_client) catch |err| {
+ std.debug.print("error: {any}\n", .{err});
+ unreachable;
+ };
+ defer allocator.free(body);
+
+ std.debug.print("body: {s}\n", .{body});
+ _ = buf;
+ return 0;
+}
+
+test "basic add functionality" {
+ try testing.expect(add(3, 7) == 10);
+}
+
+test "c api" {
+ const clientOptions = transmission.ClientOptions{
+ .host = "192.168.0.2",
+ .port = 9091,
+ .https = false,
+ };
+ var foo = c_client_init(clientOptions);
+ _ = c_session_get(foo, undefined);
+ c_client_deinit(foo);
+}
diff --git a/src/request.zig b/src/request.zig
new file mode 100644
index 0000000..950a1fc
--- /dev/null
+++ b/src/request.zig
@@ -0,0 +1,363 @@
+const std = @import("std");
+const util = @import("util.zig");
+
+const Method = enum {
+ // Torrent requests: Torrent action
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#31-torrent-action-requests
+ torrent_start,
+ torrent_start_now,
+ torrent_stop,
+ torrent_verify,
+ torrent_reannounce,
+
+ // Torrent requests: Mutator
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#32-torrent-mutator-torrent-set
+ torrent_set,
+
+ // Torrent requests: Torrent accessor
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#33-torrent-accessor-torrent-get
+ torrent_get,
+
+ // Torrent requests: Torrent adding
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#34-adding-a-torrent
+ torrent_add,
+
+ // Torrent requests: Torrent removing
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#35-removing-a-torrent
+ torrent_remove,
+
+ // Torrent requests: Torrent moving
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#36-moving-a-torrent
+ torrent_set_location,
+
+ // Torrent requests: Torrent moving
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#37-renaming-a-torrents-path
+ torrent_rename_path,
+
+ // Session requests: Get
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#4--session-requests
+ session_get,
+
+ // Session requests: Mutator
+ // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#411-mutators
+ session_set,
+ //session_stats,
+
+ const Self = @This();
+
+ // Transmission RPC uses hyphens instead of underscores, this creates a
+ // table of method names using the same enum field names but with
+ // underscores.
+ pub const Fields = util.RPCFields(Self);
+
+ pub fn str(self: Self) []const u8 {
+ return Fields[@enumToInt(self)];
+ }
+
+ pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void {
+ try std.json.stringify(self.str(), options, out_stream);
+ }
+};
+
+pub const SessionSetFields = struct {
+ alt_speed_down: ?usize = null,
+ alt_speed_enabled: ?bool = null,
+ alt_speed_time_begin: ?usize = null,
+ alt_speed_time_day: ?usize = null,
+ alt_speed_time_enabled: ?bool = null,
+ alt_speed_time_end: ?usize = null,
+ alt_speed_up: ?usize = null,
+ blocklist_enabled: ?bool = null,
+ blocklist_url: ?[]u8 = null,
+ cache_size_mb: ?usize = null,
+ default_trackers: ?[]u8 = null,
+ dht_enabled: ?bool = null,
+ download_dir: ?[]u8 = null,
+ download_dir_free_space: ?usize = null,
+ download_queue_enabled: ?bool = null,
+ download_queue_size: ?usize = null,
+ encryption: ?enum {
+ required,
+ preferred,
+ tolerated,
+
+ const Self = @This();
+
+ pub const Fields = util.RPCFields(Self);
+
+ pub fn str(self: Self) []const u8 {
+ return Fields[@enumToInt(self)];
+ }
+
+ pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void {
+ try std.json.stringify(self.str(), options, out_stream);
+ }
+ } = null,
+ idle_seeding_limit_enabled: ?bool = null,
+ idle_seeding_limit: ?usize = null,
+ incomplete_dir_enabled: ?bool = null,
+ incomplete_dir: ?[]u8 = null,
+ lpd_enabled: ?bool = null,
+ peer_limit_global: ?usize = null,
+ peer_limit_per_torrent: ?usize = null,
+ peer_port_random_on_start: ?bool = null,
+ peer_port: ?usize = null,
+ pex_enabled: ?bool = null,
+ port_forwarding_enabled: ?bool = null,
+ queue_stalled_enabled: ?bool = null,
+ queue_stalled_minutes: ?usize = null,
+ rename_partial_files: ?bool = null,
+ script_torrent_added_enabled: ?bool = null,
+ script_torrent_added_filename: ?[]u8 = null,
+ script_torrent_done_enabled: ?bool = null,
+ script_torrent_done_filename: ?[]u8 = null,
+ script_torrent_done_seeding_enabled: ?bool = null,
+ script_torrent_done_seeding_filename: ?[]u8 = null,
+ seed_queue_enabled: ?bool = null,
+ seed_queue_size: ?usize = null,
+ seedRatioLimit: ?f64 = null,
+ seedRatioLimited: ?bool = null,
+ speed_limit_down_enabled: ?bool = null,
+ speed_limit_down: ?usize = null,
+ speed_limit_up_enabled: ?bool = null,
+ speed_limit_up: ?usize = null,
+ start_added_torrents: ?bool = null,
+ trash_original_torrent_files: ?bool = null,
+ utp_enabled: ?bool = null,
+};
+
+pub const SessionGetFields = enum {
+ alt_speed_down,
+ alt_speed_enabled,
+ alt_speed_time_begin,
+ alt_speed_time_day,
+ alt_speed_time_enabled,
+ alt_speed_time_end,
+ alt_speed_up,
+ blocklist_enabled,
+ blocklist_size,
+ blocklist_url,
+ cache_size_mb,
+ config_dir,
+ default_trackers,
+ dht_enabled,
+ download_dir,
+ download_dir_free_space,
+ download_queue_enabled,
+ download_queue_size,
+ encryption,
+ idle_seeding_limit_enabled,
+ idle_seeding_limit,
+ incomplete_dir_enabled,
+ incomplete_dir,
+ lpd_enabled,
+ peer_limit_global,
+ peer_limit_per_torrent,
+ peer_port_random_on_start,
+ peer_port,
+ pex_enabled,
+ port_forwarding_enabled,
+ queue_stalled_enabled,
+ queue_stalled_minutes,
+ rename_partial_files,
+ rpc_version_minimum,
+ rpc_version_semver,
+ rpc_version,
+ script_torrent_added_enabled,
+ script_torrent_added_filename,
+ script_torrent_done_enabled,
+ script_torrent_done_filename,
+ script_torrent_done_seeding_enabled,
+ script_torrent_done_seeding_filename,
+ seed_queue_enabled,
+ seed_queue_size,
+ seedRatioLimit,
+ seedRatioLimited,
+ speed_limit_down_enabled,
+ speed_limit_down,
+ speed_limit_up_enabled,
+ speed_limit_up,
+ start_added_torrents,
+ trash_original_torrent_files,
+ units,
+ utp_enabled,
+ version,
+
+ const Self = @This();
+
+ pub const Fields = util.RPCFields(Self);
+
+ pub fn str(self: Self) []const u8 {
+ return Fields[@enumToInt(self)];
+ }
+
+ // We have to add our own custom stringification because the default
+ // one will nest the enum inside the method name.
+ pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void {
+ try std.json.stringify(self.str(), options, out_stream);
+ }
+};
+
+pub const TorrentIDs = union(enum) {
+ single: usize,
+ many: []const usize,
+ recently_active,
+
+ const Self = @This();
+
+ pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void {
+ switch (self) {
+ .single => |v| try std.json.stringify(v, options, out_stream),
+ .many => |v| try std.json.stringify(v, options, out_stream),
+ .recently_active => try std.json.stringify("recently-active", options, out_stream),
+ }
+ }
+};
+
+pub const TorrentActionFields = struct {
+ ids: TorrentIDs,
+};
+
+pub const Request = struct {
+ method: Method,
+ arguments: union(Method) {
+ torrent_start: TorrentActionFields,
+ torrent_start_now: TorrentActionFields,
+ torrent_stop: TorrentActionFields,
+ torrent_verify: TorrentActionFields,
+ torrent_reannounce: TorrentActionFields,
+
+ torrent_set: u8,
+ torrent_get: u8,
+ torrent_add: u8,
+ torrent_remove: u8,
+ torrent_set_location: u8,
+ torrent_rename_path: u8,
+
+ session_get: struct {
+ fields: []const SessionGetFields,
+ },
+
+ session_set: SessionSetFields,
+
+ const Self = @This();
+
+ pub fn jsonStringify(self: Self, _: std.json.StringifyOptions, out_stream: anytype) !void {
+ const options = std.json.StringifyOptions{
+ .emit_null_optional_fields = false,
+ };
+
+ switch (self) {
+ .torrent_start,
+ .torrent_start_now,
+ .torrent_stop,
+ .torrent_verify,
+ .torrent_reannounce,
+ => |v| try std.json.stringify(v, options, out_stream),
+
+ .session_set => |v| try std.json.stringify(v, options, out_stream),
+
+ .session_get => |v| try std.json.stringify(v, options, out_stream),
+ else => unreachable,
+ }
+ }
+ },
+};
+
+test "json request encoding" {
+ const test_case = struct {
+ name: []const u8,
+ expected: []const u8,
+ request: Request,
+
+ fn run(self: @This()) !void {
+ var req = std.ArrayList(u8).init(std.testing.allocator);
+ defer req.deinit();
+ try std.json.stringify(self.request, .{}, req.writer());
+ try std.testing.expect(std.mem.eql(u8, req.items, self.expected));
+ }
+ };
+
+ const test_cases = [_]test_case{
+ .{
+ .name = "session-get",
+ .request = .{
+ .method = .session_get,
+ .arguments = .{
+ .session_get = .{
+ .fields = &[_]SessionGetFields{
+ .version,
+ .utp_enabled,
+ },
+ },
+ },
+ },
+ .expected =
+ \\{"method":"session-get","arguments":{"fields":["version","utp-enabled"]}}
+ ,
+ },
+
+ .{
+ .name = "session-set",
+ .request = .{
+ .method = .session_set,
+ .arguments = .{
+ .session_set = .{
+ .lpd_enabled = true,
+ .encryption = .required,
+ },
+ },
+ },
+ .expected =
+ \\{"method":"session-set","arguments":{"encryption":"required","lpd_enabled":true}}
+ ,
+ },
+
+ .{
+ .name = "torrent-reannounce single id",
+ .request = .{
+ .method = .torrent_reannounce,
+ .arguments = .{
+ .torrent_reannounce = .{
+ .ids = .{ .single = 1 },
+ },
+ },
+ },
+ .expected =
+ \\{"method":"torrent-reannounce","arguments":{"ids":1}}
+ ,
+ },
+
+ .{
+ .name = "torrent-reannounce multiple id",
+ .request = .{
+ .method = .torrent_reannounce,
+ .arguments = .{
+ .torrent_reannounce = .{
+ .ids = .{ .many = &[_]usize{ 1, 2 } },
+ },
+ },
+ },
+ .expected =
+ \\{"method":"torrent-reannounce","arguments":{"ids":[1,2]}}
+ ,
+ },
+
+ .{
+ .name = "torrent-reannounce recently-active",
+ .request = .{
+ .method = .torrent_reannounce,
+ .arguments = .{
+ .torrent_reannounce = .{
+ .ids = .recently_active,
+ },
+ },
+ },
+ .expected =
+ \\{"method":"torrent-reannounce","arguments":{"ids":"recently-active"}}
+ ,
+ },
+ };
+
+ for (test_cases) |tc| try tc.run();
+}
diff --git a/src/transmission.zig b/src/transmission.zig
new file mode 100644
index 0000000..fec1b12
--- /dev/null
+++ b/src/transmission.zig
@@ -0,0 +1,106 @@
+const std = @import("std");
+
+const Request = @import("request.zig").Request;
+const SessionGetFields = @import("request.zig").SessionGetFields;
+
+pub const ClientOptions = extern struct {
+ host: [*:0]const u8,
+ port: u16,
+ https: bool,
+ user: ?[*:0]const u8 = null,
+ password: ?[*:0]const u8 = null,
+};
+
+pub const Client = struct {
+ uri: std.Uri,
+ allocator: std.mem.Allocator,
+ http_client: std.http.Client,
+ http_headers: std.http.Headers,
+ current_session_id: []u8 = "",
+
+ pub fn init(allocator: std.mem.Allocator, opts: ClientOptions) Client {
+ const base_url = std.Uri{
+ .path = "/transmission/rpc",
+ .scheme = blk: {
+ if (opts.https) {
+ break :blk "https";
+ } else {
+ break :blk "http";
+ }
+ },
+ .host = std.mem.span(opts.host),
+ .port = opts.port,
+ .user = null,
+ .password = null,
+ .query = null,
+ .fragment = null,
+ };
+
+ return Client{
+ .uri = base_url,
+ .allocator = allocator,
+ .http_client = std.http.Client{ .allocator = allocator },
+ .http_headers = std.http.Headers{ .allocator = allocator },
+ };
+ }
+
+ pub fn deinit(self: *Client) void {
+ self.http_headers.deinit();
+ self.http_client.deinit();
+ self.allocator.free(self.current_session_id);
+ }
+
+ fn setSessionId(self: *Client, r: std.http.Client.Response) !void {
+ self.allocator.free(self.current_session_id);
+ const session_id = r.headers.getFirstValue("X-Transmission-Session-Id") orelse return error.NoSessionId;
+ self.current_session_id = try std.fmt.allocPrint(self.allocator, "{s}", .{session_id});
+ }
+
+ fn newRequest(self: *Client) !std.http.Client.Request {
+ try self.http_headers.append("X-Transmission-Session-Id", self.current_session_id);
+ return try self.http_client.request(.POST, self.uri, self.http_headers, .{});
+ }
+
+ pub fn do(self: *Client, req: Request) ![]u8 {
+ var real_req = try self.newRequest();
+ defer real_req.deinit();
+
+ var payload = std.ArrayList(u8).init(self.allocator);
+ defer payload.deinit();
+
+ try std.json.stringify(req, .{}, payload.writer());
+
+ real_req.transfer_encoding = std.http.Client.RequestTransfer{
+ .content_length = payload.items.len,
+ };
+
+ try real_req.start();
+ if (try real_req.write(payload.items) != payload.items.len) {
+ return error.InvalidSize;
+ }
+
+ try real_req.finish();
+ try real_req.wait();
+
+ if (real_req.response.status == std.http.Status.conflict) {
+ try self.setSessionId(real_req.response);
+ return self.do(req);
+ }
+
+ const body = try real_req.reader().readAllAlloc(self.allocator, 9000);
+ return body;
+ }
+};
+
+pub fn sessionGet(client: *Client) ![]u8 {
+ const r = Request{
+ .method = .session_get,
+ .arguments = .{
+ .session_get = .{
+ .fields = &[_]SessionGetFields{ .version, .utp_enabled },
+ },
+ },
+ };
+ const body = try client.do(r);
+ return body;
+}
diff --git a/src/util.zig b/src/util.zig
new file mode 100644
index 0000000..b701ff6
--- /dev/null
+++ b/src/util.zig
@@ -0,0 +1,14 @@
+const std = @import("std");
+
+// Returns an array of all the fields in the given
+// type replacing all '_' with '-'.
+pub fn RPCFields(comptime E: type) []const []const u8 {
+ @setEvalBranchQuota(10000);
+ var names: []const []const u8 = &[_][]const u8{};
+ for (std.meta.fields(E)) |field| {
+ var name: [field.name.len]u8 = undefined;
+ _ = std.mem.replace(u8, field.name, &[_]u8{'_'}, &[_]u8{'-'}, &name);
+ names = names ++ &[_][]const u8{&name};
+ }
+ return names;
+}
diff --git a/test.c b/test.c
new file mode 100644
index 0000000..13a2af5
--- /dev/null
+++ b/test.c
@@ -0,0 +1,13 @@
+// This header is generated by zig from mathtest.zig
+#include "xmission.h"
+#include <stdio.h>
+
+int main(int argc, char **argv) {
+ struct ClientOptions opts;
+ opts.host = "192.168.0.2";
+ opts.port = 9091;
+
+ c_client client = c_client_init(opts);
+ c_client_deinit(client);
+ return 0;
+}
diff --git a/xmission.h b/xmission.h
new file mode 100644
index 0000000..be1d8d7
--- /dev/null
+++ b/xmission.h
@@ -0,0 +1,20 @@
+#ifndef _XMISSION_H
+
+#define _XMISSION_H
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+typedef void *c_client;
+
+typedef struct ClientOptions {
+ char *host;
+ uint16_t port;
+ bool https;
+ char *user;
+ char *passowrd;
+} ClientOptions_t;
+
+c_client c_client_init(ClientOptions_t opts);
+void c_client_deinit(c_client);
+#endif