Changes
3 changed files (+218/-1)
-
-
@@ -0,0 +1,189 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const clap = @import("clap"); const core = @import("core"); const connection = @import("../connection.zig"); const State = @import("../State.zig"); const ExitCode = @import("../exit.zig").ExitCode; const OutputFormat = enum { text, tsv, jsonl, }; const parser = .{ .format = clap.parsers.enumeration(OutputFormat), }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\--header Whether emits header row (only on --format=tsv) \\-f, --format <format> Output format. \\Available values are: \\* text ... Plain text format for human consumption. \\* tsv ... TSV (tab-separated values). \\* jsonl ... Newline delimited list of JSON documents. \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const conn = connection.make(allocator) catch |err| { std.log.err("Unable to establish a connection: {s}", .{@errorName(err)}); return switch (err) { error.OutOfMemory => ExitCode.out_of_memory, else => ExitCode.not_ok, }; }; defer conn.release(); while (true) { const ev = conn.getEvent() orelse { std.log.err("Got empty event, terminating", .{}); return ExitCode.not_ok; }; defer ev.release(); switch (ev.internal.payload) { .connection_error => |err_ev| { std.log.err("Failed to read event: {s}", .{@tagName(err_ev.code)}); return ExitCode.not_ok; }, .connected => { break; }, else => { continue; }, } } const loop_thread = std.Thread.spawn(.{}, consumeEvent, .{conn}) catch |err| { std.log.err("Failed to spawn message read thread: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; defer { conn.disconnect(); loop_thread.join(); } const browse_result = conn.requestBrowse(.albums, null, null, false) orelse { std.log.err("Failed to browse albums: OutOfMemory", .{}); return ExitCode.out_of_memory; }; defer browse_result.release(); if (browse_result.code != .ok) { std.log.err("Failed to browse albums: {s}", .{@tagName(browse_result.code)}); return if (browse_result.code == .out_of_memory) ExitCode.out_of_memory else ExitCode.not_ok; } if (browse_result.action != .list) { std.log.err("Expected `list` response, found `{s}`", .{@tagName(browse_result.action)}); return ExitCode.not_ok; } const list = browse_result.internal.payload.list; const stdout = std.io.getStdOut().writer(); switch (res.args.format orelse .text) { .text => { for (list.items_ptr[0..list.items_len]) |item| { std.fmt.format( stdout, "Title=\"{s}\" Artist=\"{s}\"\n", .{ item.title, item.subtitle orelse "" }, ) catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } }, .tsv => { if (res.args.header > 0) { stdout.writeAll("Title\tArtist\n") catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } for (list.items_ptr[0..list.items_len]) |item| { stdout.print( "{s}\t{s}\n", .{ item.title, item.subtitle orelse "" }, ) catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } }, .jsonl => { for (list.items_ptr[0..list.items_len]) |item| { stdout.print("{}\n", .{ std.json.fmt(ItemPrinter{.item = item}, .{ .whitespace = .minified }), }) catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } }, } return ExitCode.ok; } const ItemPrinter = struct { item: *const core.browse.Item, pub fn jsonStringify(self: *const ItemPrinter, jws: anytype) !void { try jws.beginObject(); try jws.objectField("title"); try jws.write(self.item.title); try jws.objectField("artist"); try jws.write(self.item.subtitle); try jws.endObject(); } }; fn consumeEvent(conn: *core.connection.Connection) void { const ev = conn.getEvent() orelse { return; }; defer ev.release(); }
-
-
-
@@ -19,6 +19,7 @@ const clap = @import("clap");const core = @import("core"); const ExitCode = @import("./exit.zig").ExitCode; const albums = @import("./commands/albums.zig"); const connect = @import("./commands/connect.zig"); const playback = @import("./commands/playback.zig"); const server = @import("./commands/server.zig");
-
@@ -45,6 +46,7 @@ }const Commands = enum { connect, albums, playback, server, version,
-
@@ -64,6 +66,7 @@ \\-v, --verbose Enables debug logging.\\<command> \\Available commands: \\* version ... Prints version to stdout and exits. \\* albums ... List albums in library. \\* playback ... Display playback state of a zone. \\* server ... Lists or gets information of Roon Server on network \\* connect ... Connects to Roon server and save state to a file.
-
@@ -111,6 +114,7 @@ return @intFromEnum(ExitCode.incorrect_usage);}; switch (command) { .albums => return @intFromEnum(albums.run(allocator, &iter)), .playback => return @intFromEnum(playback.run(allocator, &iter)), .server => return @intFromEnum(server.run(allocator, &iter)), .version => {
-
-
-
@@ -467,6 +467,26 @@ allocator.destroy(self);} } pub fn disconnect(ptr: ?*Connection) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.ws) |*ws| { std.log.debug("Closing WebSocket connection...", .{}); // websocket.zig cannot properly close connection, when using unmanaged read. // As a workaround, we use manual termination snippet from: // https://github.com/karlseguin/websocket.zig/issues/46 std.posix.shutdown(ws.stream.stream.handle, .both) catch |err| { std.log.warn("WebSocket shutdown failed, ignoring: {s}", .{@errorName(err)}); }; std.posix.close(ws.stream.stream.handle); ws.deinit(); self.internal.ws = null; } } pub fn getEvent(ptr: ?*Connection) callconv(.C) ?*Event { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }),
-
@@ -497,7 +517,11 @@ };while (true) { const meta, const header_ctx, const msg = readMessage(&ws) catch |err| { std.log.err("Failed to read a message: {s}", .{@errorName(err)}); if (err == error.ReadClosedConnection) { std.log.debug("WebSocket connection is closed", .{}); } else { std.log.err("Failed to read a message: {s}", .{@errorName(err)}); } const event = Event.makeConnectionError(err) catch |make_err| { std.log.err("Unable to compose {s} event: {s}", .{ @tagName(Event.Kind.connection_error),
-