Changes
8 changed files (+1413/-3)
-
core/src/browse.zig (new)
-
@@ -0,0 +1,661 @@// 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 Arc = @import("./Arc.zig"); const BrowseService = @import("./services/browse.zig").BrowseService; pub const Hierarchy = enum(c_int) { browse = 0, playlists = 1, settings = 2, internet_radio = 3, albums = 4, artists = 5, genres = 6, composers = 7, search = 8, }; pub const ItemHint = enum(c_int) { unknown = 0, action = 1, action_list = 2, list = 3, header = 4, }; pub const InputPrompt = extern struct { const cname = "plac_browse_input_prompt"; const allocator = std.heap.c_allocator; internal: *Internal, prompt: [*:0]const u8, action: [*:0]const u8, default_value: ?[*:0]const u8, is_password: bool, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const BrowseService.Item.InputPrompt) std.mem.Allocator.Error!*InputPrompt { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const prompt = try allocator.dupeZ(u8, src.prompt); errdefer allocator.free(prompt); const action = try allocator.dupeZ(u8, src.action); errdefer allocator.free(action); const default_value = if (src.value) |value| try allocator.dupeZ(u8, value) else null; errdefer if (default_value) |slice| allocator.free(slice); const self = try allocator.create(InputPrompt); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .prompt = prompt.ptr, .action = action.ptr, .default_value = if (default_value) |slice| slice.ptr else null, .is_password = src.is_password, }; return self; } pub fn retain(ptr: ?*InputPrompt) callconv(.C) *InputPrompt { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*InputPrompt) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { std.log.debug("Releasing {*}...", .{self}); allocator.free(std.mem.span(self.prompt)); allocator.free(std.mem.span(self.action)); if (self.default_value) |default_value| { allocator.free(std.mem.span(default_value)); } allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const Item = extern struct { const cname = "plac_browse_item"; const allocator = std.heap.c_allocator; internal: *Internal, title: [*:0]const u8, subtitle: ?[*:0]const u8, image_key: ?[*:0]const u8, item_key: ?[*:0]const u8, hint: ItemHint, prompt: ?*InputPrompt, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const BrowseService.Item) std.mem.Allocator.Error!*Item { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const title = try allocator.dupeZ(u8, src.title); errdefer allocator.free(title); const subtitle = if (src.subtitle) |subtitle| try allocator.dupeZ(u8, subtitle) else null; errdefer if (subtitle) |slice| allocator.free(slice); const image_key = if (src.image_key) |key| try allocator.dupeZ(u8, key) else null; errdefer if (image_key) |slice| allocator.free(slice); const item_key = if (src.item_key) |key| try allocator.dupeZ(u8, key) else null; errdefer if (item_key) |slice| allocator.free(slice); const prompt = if (src.input_prompt) |*input_prompt| prompt: { const p = try InputPrompt.make(input_prompt); break :prompt p.retain(); } else null; errdefer if (prompt) |p| p.release(); const self = try allocator.create(Item); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .title = title, .subtitle = if (subtitle) |slice| slice.ptr else null, .image_key = if (image_key) |slice| slice.ptr else null, .item_key = if (item_key) |slice| slice.ptr else null, .hint = switch (src.hint) { .unknown => .unknown, .action => .action, .action_list => .action_list, .header => .header, .list => .list, }, .prompt = prompt, }; return self; } pub fn retain(ptr: ?*Item) callconv(.C) *Item { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*Item) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { std.log.debug("Releasing {*}...", .{self}); if (self.prompt) |prompt| prompt.release(); if (self.item_key) |item_key| allocator.free(std.mem.span(item_key)); if (self.image_key) |image_key| allocator.free(std.mem.span(image_key)); if (self.subtitle) |subtitle| allocator.free(std.mem.span(subtitle)); allocator.free(std.mem.span(self.title)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ReplaceItemAction = extern struct { const cname = "plac_browse_replace_item_action"; const allocator = std.heap.c_allocator; internal: *Internal, item: *Item, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(item_src: *const BrowseService.Item) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const item = try Item.make(item_src); _ = item.retain(); errdefer item.release(); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .item = item, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { std.log.debug("Releasing {*}...", .{self}); self.item.release(); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ListAction = extern struct { const cname = "plac_browse_list_action"; const allocator = std.heap.c_allocator; internal: *Internal, title: [*:0]const u8, subtitle: ?[*:0]const u8, image_key: ?[*:0]const u8, level: u64, items_ptr: [*]*Item, items_len: usize, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const BrowseService.Load.Response) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const title = try allocator.dupeZ(u8, src.list.title); errdefer allocator.free(title); const subtitle = if (src.list.subtitle) |st| try allocator.dupeZ(u8, st) else null; errdefer if (subtitle) |st| allocator.free(st); const image_key = if (src.list.image_key) |ik| try allocator.dupeZ(u8, ik) else null; errdefer if (image_key) |ik| allocator.free(ik); const items = try allocator.alloc(*Item, src.items.len); errdefer allocator.free(items); var items_i: usize = 0; errdefer for (0..items_i) |i| { items[i].release(); }; for (src.items) |*item| { items[items_i] = try Item.make(item); _ = items[items_i].retain(); items_i += 1; } const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .title = title.ptr, .subtitle = if (subtitle) |st| st.ptr else null, .image_key = if (image_key) |ik| ik.ptr else null, .level = src.list.level, .items_ptr = items.ptr, .items_len = items.len, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { std.log.debug("Releasing {*}...", .{self}); const items = self.items_ptr[0..self.items_len]; for (items) |item| { item.release(); } allocator.free(items); if (self.image_key) |image_key| allocator.free(std.mem.span(image_key)); if (self.subtitle) |subtitle| allocator.free(std.mem.span(subtitle)); allocator.free(std.mem.span(self.title)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ErrorMessageAction = extern struct { const cname = "plac_browse_error_message_action"; const allocator = std.heap.c_allocator; internal: *Internal, message: [*:0]const u8, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(message_src: []const u8) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const message = try allocator.dupeZ(u8, message_src); errdefer allocator.free(message); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .message = message.ptr, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { std.log.debug("Releasing {*}...", .{self}); allocator.free(std.mem.span(self.message)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const MessageAction = extern struct { const cname = "plac_browse_message_action"; const allocator = std.heap.c_allocator; internal: *Internal, message: [*:0]const u8, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(message_src: []const u8) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const message = try allocator.dupeZ(u8, message_src); errdefer allocator.free(message); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .message = message.ptr, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { std.log.debug("Releasing {*}...", .{self}); allocator.free(std.mem.span(self.message)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ResultCode = enum(c_int) { ok = 0, unknown_error = 1, missing_property = 2, out_of_memory = 3, failed_to_send = 4, closed = 5, }; pub const ResultAction = enum(c_int) { none = 0, replace_item = 1, remove_item = 2, list = 3, error_message = 4, message = 5, }; pub const Result = extern struct { const cname = "plac_browse_result"; const allocator = std.heap.c_allocator; internal: *Internal, code: ResultCode, action: ResultAction = .none, pub const Payload = union(ResultAction) { none: void, replace_item: *ReplaceItemAction, remove_item: void, list: *ListAction, error_message: *ErrorMessageAction, message: *MessageAction, }; pub const Internal = struct { arc: Arc = .{}, payload: Payload = .none, }; fn makeError(code: ResultCode) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .code = code, }; return self; } pub fn makeRetainedError(code: ResultCode) std.mem.Allocator.Error!*@This() { const result = try makeError(code); return result.retain(); } fn make(payload: Payload) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); switch (payload) { .replace_item => |action| _ = action.retain(), .list => |action| _ = action.retain(), .error_message => |action| _ = action.retain(), .message => |action| _ = action.retain(), else => {}, } errdefer switch (payload) { .replace_item => |action| action.release(), .list => |action| action.release(), .error_message => |action| action.release(), .message => |action| action.release(), else => {}, }; internal.* = .{ .payload = payload, }; const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .code = .ok, .action = switch (payload) { .none => .none, .replace_item => .replace_item, .remove_item => .remove_item, .list => .list, .error_message => .error_message, .message => .message, }, }; return self; } pub fn makeRetained(payload: Payload) std.mem.Allocator.Error!*@This() { const result = try make(payload); return result.retain(); } fn exportActionGetter(comptime Action: type, tag: ResultAction, name: []const u8) void { const Getter = struct { fn get(ptr: ?*Result) callconv(.C) *Action { const self = ptr orelse @panic( std.fmt.comptimePrint( "Received null pointer on {s}_{s}", .{ cname, name }, ), ); if (@intFromEnum(self.internal.payload) == @intFromEnum(tag)) { return @field(self.internal.payload, @tagName(tag)).retain(); } else { std.log.err("{s}_{s} called on {s}", .{ cname, name, @tagName(self.internal.payload), }); @panic(std.fmt.comptimePrint("Union tag mismatch on {s}_{s}", .{ cname, name })); } } }; @export(&Getter.get, .{ .name = std.fmt.comptimePrint("{s}_{s}", .{ cname, name }) }); } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { std.log.debug("Releasing {*}...", .{self}); switch (self.internal.payload) { .replace_item => |action| action.release(), .list => |action| action.release(), .error_message => |action| action.release(), .message => |action| action.release(), else => {}, } allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); exportActionGetter(ReplaceItemAction, .replace_item, "get_replace_item_action"); exportActionGetter(ListAction, .list, "get_list_action"); exportActionGetter(ErrorMessageAction, .error_message, "get_error_message_action"); exportActionGetter(MessageAction, .message, "get_message_action"); } }; pub fn export_capi() void { InputPrompt.export_capi(); Item.export_capi(); ReplaceItemAction.export_capi(); ListAction.export_capi(); ErrorMessageAction.export_capi(); MessageAction.export_capi(); Result.export_capi(); }
-
-
-
@@ -20,8 +20,10 @@ const moo = @import("moo");const websocket = @import("websocket"); const Arc = @import("./Arc.zig"); const browse = @import("./browse.zig"); const discovery = @import("./discovery.zig"); const extension = @import("./extension.zig").extension; const BrowseService = @import("./services/browse.zig").BrowseService; const ping = @import("./services/ping.zig"); const registry = @import("./services/registry.zig"); const TransportService = @import("./services/transport.zig").TransportService;
-
@@ -330,9 +332,30 @@ Event.exportEventCastFunction(transport.ZoneListEvent, .zone_list);} }; fn JsonResponseListener(comptime T: type) type { return struct { wrote: std.Thread.ResetEvent = .{}, data: ?moo.JsonBody(T) = null, pub fn listen(self: *@This()) moo.JsonBody(T) { self.wrote.wait(); return self.data orelse @panic("Set JsonResponseListener.wrote before writing data"); } pub fn write(self: *@This(), data: moo.JsonBody(T)) void { self.data = data; self.wrote.set(); } }; } pub const Connection = extern struct { const cname = "plac_connection"; const allocator = std.heap.c_allocator; const BrowseListener = JsonResponseListener(BrowseService.Browse.Response); const LoadListener = JsonResponseListener(BrowseService.Load.Response); internal: *Internal,
-
@@ -345,6 +368,8 @@ host: []const u8,zone_subscription_request_id: ?i64 = null, arc: Arc = .{}, saved_token: ?[]const u8 = null, browse_listeners: std.AutoHashMap(i64, BrowseListener), load_listeners: std.AutoHashMap(i64, LoadListener), fn init(server: *discovery.Server, token: ?[]const u8) !Internal { var addr = std.ArrayList(u8).init(allocator);
-
@@ -372,10 +397,15 @@ return .{.server = server.retain(), .host = host, .saved_token = saved_token, .browse_listeners = std.AutoHashMap(i64, BrowseListener).init(allocator), .load_listeners = std.AutoHashMap(i64, LoadListener).init(allocator), }; } fn deinit(self: *Internal) void { self.browse_listeners.deinit(); self.load_listeners.deinit(); if (self.ws) |*ws| { ws.deinit(); }
-
@@ -546,9 +576,42 @@ continue;} } std.log.debug("Got message on {s}", .{meta.service}); if (self.internal.browse_listeners.getEntry(header.request_id)) |entry| { std.log.debug("Received /browse response (ID={d})", .{header.request_id}); // TODO: Return an event const res = BrowseService.Browse.Response.parse( allocator, &meta, header_ctx, msg.data, ) catch |err| { std.log.err("Received unexpected browse response: {s}", .{@errorName(err)}); continue; }; entry.value_ptr.write(res); continue; } if (self.internal.load_listeners.getEntry(header.request_id)) |entry| { std.log.debug("Received /load response (ID={d})", .{header.request_id}); const res = BrowseService.Load.Response.parse( allocator, &meta, header_ctx, msg.data, ) catch |err| { std.log.err("Received unexpected load response: {s}", .{@errorName(err)}); continue; }; entry.value_ptr.write(res); continue; } std.log.warn("Unhandle message on {s}", .{meta.service}); std.log.debug("{s}", .{msg.data}); } }
-
@@ -797,6 +860,145 @@ return;}; } pub fn requestBrowse( ptr: ?*Connection, hierarchy: browse.Hierarchy, zone: ?*transport.Zone, item: ?*browse.Item, pop: bool, ) callconv(.C) ?*browse.Result { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (zone) |z| _ = z.retain(); defer if (zone) |z| z.release(); if (item) |i| _ = i.retain(); defer if (item) |i| i.release(); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return browse.Result.makeRetainedError(.closed) catch null; }; const req_id = self.internal.request_id; self.internal.request_id += 1; std.log.debug("Sending browse request...", .{}); const req = BrowseService.Browse.Request{ .hierarchy = @tagName(hierarchy), .zone_or_output_id = if (zone) |z| std.mem.span(z.id) else null, .item_key = if (item) |i| if (i.item_key) |key| std.mem.span(key) else null else null, .pop_all = item == null and !pop, .pop_levels = if (pop) 1 else null, }; const req_msg = req.encode(allocator, req_id) catch |err| { std.log.err("Unable to compose browse request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; defer allocator.free(req_msg); var entry = self.internal.browse_listeners.getOrPut(req_id) catch |err| { std.log.err("Unable to set listener for browse response: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; entry.value_ptr.* = .{}; defer _ = self.internal.browse_listeners.remove(req_id); ws.writeBin(req_msg) catch |err| { std.log.err("Unable to write browse request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.failed_to_send) catch null; }; std.log.debug("Sent browse request (ID={d})", .{req_id}); const resp = entry.value_ptr.listen(); defer resp.deinit(); switch (resp.value.action) { .message => { const message = resp.value.message orelse { std.log.err("Got `message` action, but `message` property is missing", .{}); return browse.Result.makeRetainedError(.missing_property) catch null; }; if (resp.value.is_error orelse false) { const action = browse.ErrorMessageAction.make(message) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .error_message = action }) catch null; } else { const action = browse.MessageAction.make(message) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .message = action }) catch null; } }, .replace_item => { const replace_item = resp.value.item orelse { std.log.err("Got `replace_item` action, but `item` property is missing", .{}); return browse.Result.makeRetainedError(.missing_property) catch null; }; const action = browse.ReplaceItemAction.make(&replace_item) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .replace_item = action }) catch null; }, .remove_item => { return browse.Result.makeRetained(.remove_item) catch null; }, .list => { const list = resp.value.list orelse { std.log.err("Got `list` action, but `list` property is missing", .{}); return browse.Result.makeRetainedError(.missing_property) catch null; }; const load_req = BrowseService.Load.Request{ .hierarchy = @tagName(hierarchy), .count = std.math.maxInt(u16), .level = list.level, }; const load_req_id = self.internal.request_id; self.internal.request_id += 1; const load_req_msg = load_req.encode(allocator, load_req_id) catch |err| { std.log.err("Unable to compose load request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; defer allocator.free(load_req_msg); var load_entry = self.internal.load_listeners.getOrPut(load_req_id) catch |err| { std.log.err("Unable to set listener for load response: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; load_entry.value_ptr.* = .{}; defer _ = self.internal.load_listeners.remove(load_req_id); ws.writeBin(load_req_msg) catch |err| { std.log.err("Unable to write load request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.failed_to_send) catch null; }; std.log.debug("Sent load request (ID={d})", .{load_req_id}); const load_resp = load_entry.value_ptr.listen(); defer load_resp.deinit(); const action = browse.ListAction.make(load_resp.value) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .list = action }) catch null; }, .none => { return browse.Result.makeRetained(.none) catch null; }, } } pub fn export_capi() void { @export(&make, .{ .name = std.fmt.comptimePrint("{s}_make", .{cname}) }); @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) });
-
@@ -804,6 +1006,7 @@ @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) });@export(&getEvent, .{ .name = std.fmt.comptimePrint("{s}_get_event", .{cname}) }); @export(&subscribeZones, .{ .name = std.fmt.comptimePrint("{s}_subscribe_zones", .{cname}) }); @export(&control, .{ .name = std.fmt.comptimePrint("{s}_control", .{cname}) }); @export(&requestBrowse, .{ .name = std.fmt.comptimePrint("{s}_browse", .{cname}) }); } };
-
-
-
@@ -15,6 +15,7 @@ //// SPDX-License-Identifier: Apache-2.0 const Extension = @import("./services/registry.zig").Extension; const BrowseService = @import("./services/browse.zig").BrowseService; const PingService = @import("./services/ping.zig").PingService; const TransportService = @import("./services/transport.zig").TransportService;
-
@@ -24,7 +25,7 @@ .display_name = "Plac",.version = "0.0.0-dev", .publisher = "Shota FUJI", .email = "pockawoooh@gmail.com", .required_services = &.{TransportService.id}, .required_services = &.{ TransportService.id, BrowseService.id }, .optional_services = &.{}, .provided_services = &.{PingService.id}, };
-
-
-
@@ -14,11 +14,13 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 pub const browse = @import("./browse.zig"); pub const connection = @import("./connection.zig"); pub const discovery = @import("./discovery.zig"); pub const transport = @import("./transport.zig"); pub fn export_capi() void { browse.export_capi(); connection.export_capi(); discovery.export_capi(); transport.export_capi();
-
-
-
@@ -133,6 +133,121 @@ } plac_transport_zone_list_event;plac_transport_zone_list_event *plac_transport_zone_list_event_retain(plac_transport_zone_list_event*); void plac_transport_zone_list_event_release(plac_transport_zone_list_event*); // browse.Hierarchy typedef enum { PLAC_BROWSE_HIERARCHY_BROWSE = 0, PLAC_BROWSE_HIERARCHY_PLAYLISTS = 1, PLAC_BROWSE_HIERARCHY_SETTINGS = 2, PLAC_BROWSE_HIERARCHY_INTERNET_RADIO = 3, PLAC_BROWSE_HIERARCHY_ALBUMS = 4, PLAC_BROWSE_HIERARCHY_ARTISTS = 5, PLAC_BROWSE_HIERARCHY_GENRES = 6, PLAC_BROWSE_HIERARCHY_COMPOSERS = 7, PLAC_BROWSE_HIERARCHY_SEARCH = 8, } plac_browse_hierarchy; // browse.ItemHint typedef enum { PLAC_BROWSE_ITEM_HINT_UNKNOWN = 0, PLAC_BROWSE_ITEM_HINT_ACTION = 1, PLAC_BROWSE_ITEM_HINT_ACTION_LIST = 2, PLAC_BROWSE_ITEM_HINT_LIST = 3, PLAC_BROWSE_ITEM_HINT_HEADER = 4, } plac_browse_item_hint; // browse.InputPrompt typedef struct { void *__pri; const char* prompt; const char* action; const char* default_value; bool is_password; } plac_browse_input_prompt; plac_browse_input_prompt *plac_browse_input_prompt_retain(plac_browse_input_prompt*); void plac_browse_input_prompt_release(plac_browse_input_prompt*); // browse.Item typedef struct { void *__pri; const char *title; const char *subtitle; const char *image_key; plac_browse_item_hint hint; plac_browse_input_prompt *prompt; } plac_browse_item; plac_browse_item *plac_browse_item_retain(plac_browse_item*); void plac_browse_item_release(plac_browse_item*); // browse.ResultCode typedef enum { PLAC_BROWSE_RESULT_OK = 0, PLAC_BROWSE_RESULT_UNKNOWN_ERROR = 1, PLAC_BROWSE_RESULT_MISSING_PROPERTY = 2, PLAC_BROWSE_RESULT_OUT_OF_MEMORY = 3, PLAC_BROWSE_RESULT_FAILED_TO_SEND = 4, PLAC_BROWSE_RESULT_CLOSED = 5, } plac_browse_result_code; // browse.ResultAction typedef enum { PLAC_BROWSE_RESULT_ACTION_NONE = 0, PLAC_BROWSE_RESULT_ACTION_REPLACE_ITEM = 1, PLAC_BROWSE_RESULT_ACTION_REMOVE_ITEM = 2, PLAC_BROWSE_RESULT_ACTION_LIST = 3, PLAC_BROWSE_RESULT_ACTION_ERROR_MESSAGE = 4, PLAC_BROWSE_RESULT_ACTION_MESSAGE = 5, } plac_browse_result_action; // browse.ReplaceItemAction typedef struct { void *__pri; plac_browse_item *item; } plac_browse_replace_item_action; plac_browse_replace_item_action *plac_browse_replace_item_action_retain(plac_browse_replace_item_action*); void plac_browse_replace_item_action_release(plac_browse_replace_item_action*); // browse.ListAction typedef struct { void *__pri; const char *title; const char *subtitle; const char *image_key; uint64_t level; plac_browse_item **items_ptr; size_t items_len; } plac_browse_list_action; plac_browse_list_action *plac_browse_list_action_retain(plac_browse_list_action*); void plac_browse_list_action_release(plac_browse_list_action*); // browse.ErrorMessageAction typedef struct { void *__pri; const char *message; } plac_browse_error_message_action; plac_browse_error_message_action *plac_browse_error_message_action_retain(plac_browse_error_message_action*); void plac_browse_error_message_action_release(plac_browse_error_message_action*); // browse.MessageAction typedef struct { void *__pri; const char *message; } plac_browse_message_action; plac_browse_message_action *plac_browse_message_action_retain(plac_browse_message_action*); void plac_browse_message_action_release(plac_browse_message_action*); // browse.Result typedef struct { void *__pri; plac_browse_result_code code; plac_browse_result_action action; } plac_browse_result; plac_browse_result *plac_browse_result_retain(plac_browse_result*); void plac_browse_result_release(plac_browse_result*); plac_browse_replace_item_action *plac_browse_result_get_replace_item_action(plac_browse_result*); plac_browse_list_action *plac_browse_result_get_list_action(plac_browse_result*); plac_browse_error_message_action *plac_browse_result_get_error_message_action(plac_browse_result*); plac_browse_message_action *plac_browse_result_get_message_action(plac_browse_result*); // connection.ConnectedEvent typedef struct { void *__pri;
-
@@ -185,5 +300,6 @@ void plac_connection_release(plac_connection*);plac_connection_event *plac_connection_get_event(plac_connection*); void plac_connection_subscribe_zones(plac_connection*); void plac_connection_control(plac_connection*, plac_transport_zone*, uint16_t action); plac_browse_result *plac_connection_browse(plac_connection*, plac_browse_hierarchy, plac_transport_zone*, plac_browse_item*, bool pop); #endif
-
-
-
@@ -201,6 +201,158 @@ [CCode (cname = "PLAC_TRANSPORT_ACTION_SEEK")]public const uint16 ACTION_SEEK; } namespace Browse { [CCode ( cname = "plac_browse_hierarchy", cprefix = "PLAC_BROWSE_HIERARCHY_", has_type_id = false )] public enum Hierarchy { BROWSE = 0, PLAYLISTS = 1, SETTINGS = 2, INTERNET_RADIO = 3, ALBUMS = 4, ARTISTS = 5, GENRES = 6, COMPOSERS = 7, SEARCH = 8, } [CCode ( cname = "plac_browse_item_hint", cprefix = "PLAC_BROWSE_ITEM_HINT_", has_type_id = false )] public enum ItemHint { UNKNOWN = 0, ACTION = 1, ACTION_LIST = 2, LIST = 3, HEADER = 4, } [CCode ( cname = "plac_browse_input_prompt", ref_function = "plac_browse_input_prompt_retain", unref_function = "plac_browse_input_prompt_release" )] [Compact] public class InputPrompt { public string prompt; public string action; public string? default_value; public bool is_password; } [CCode ( cname = "plac_browse_item", ref_function = "plac_browse_item_retain", unref_function = "plac_browse_item_release" )] [Compact] public class Item { public string title; public string? subtitle; public string? image_key; public ItemHint hint; public InputPrompt prompt; } [CCode ( cname = "plac_browse_result_code", cprefix = "PLAC_BROWSE_RESULT_", has_type_id = false )] public enum ResultCode { OK = 0, UNKNOWN_ERROR = 1, MISSING_PROPERTY = 2, OUT_OF_MEMORY = 3, FAILED_TO_SEND = 4, CLOSED = 5, } [CCode ( cname = "plac_browse_result_action", cprefix = "PLAC_BROWSE_RESULT_ACTION_", has_type_id = false )] public enum ResultAction { NONE = 0, REPLACE_ITEM = 1, REMOVE_ITEM = 2, LIST = 3, ERROR_MESSAGE = 4, MESSAGE = 5, } [CCode ( cname = "plac_browse_replace_item_action", ref_function = "plac_browse_replace_item_action_retain", unref_function = "plac_browse_replace_item_action_release" )] [Compact] public class ReplaceItemAction { public Item item; } [CCode ( cname = "plac_browse_list_action", ref_function = "plac_browse_list_action_retain", unref_function = "plac_browse_list_action_release" )] [Compact] public class ListAction { public string title; public string? subtitle; public string? image_key; public uint64 level; [CCode ( cname = "items_ptr", array_length_cname = "items_len", array_length_type = "size_t" )] public Item[] items; } [CCode ( cname = "plac_browse_error_message_action", ref_function = "plac_browse_error_message_action_retain", unref_function = "plac_browse_error_message_action_release" )] [Compact] public class ErrorMessageAction { public string message; } [CCode ( cname = "plac_browse_message_action", ref_function = "plac_browse_message_action_retain", unref_function = "plac_browse_message_action_release" )] [Compact] public class MessageAction { public string message; } [CCode ( cname = "plac_browse_result", ref_function = "plac_browse_result_retain", unref_function = "plac_browse_result_release" )] [Compact] public class Result { public ResultCode code; public ResultAction action; public ReplaceItemAction get_replace_item_action(); public ListAction get_list_action(); public ErrorMessageAction get_error_message_action(); public MessageAction get_message_action(); } } [CCode ( cname = "plac_connection_connection_error", cprefix = "PLAC_CONNECTION_ERROR_",
-
@@ -307,5 +459,8 @@ public void subscribe_zones();[CCode (cname = "plac_connection_control")] public void control(Transport.Zone zone, uint16 action); [CCode (cname = "plac_connection_browse")] public Browse.Result? browse(Browse.Hierarchy hierarchy, Transport.Zone? zone, Browse.Item? item, bool pop); } }
-
-
-
@@ -0,0 +1,259 @@// 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 moo = @import("moo"); pub const BrowseService = struct { pub const id = "com.roonlabs.browse:1"; pub const Item = struct { title: []const u8, subtitle: ?[]const u8 = null, image_key: ?[]const u8 = null, item_key: ?[]const u8 = null, hint: Hint = .unknown, input_prompt: ?InputPrompt = null, pub const Hint = enum { unknown, action, action_list, list, header, pub fn jsonParseFromValue( _: std.mem.Allocator, value: std.json.Value, _: std.json.ParseOptions, ) std.json.ParseFromValueError!@This() { return switch (value) { .string => |v| { inline for (@typeInfo(@This()).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, "unknown")) { continue; } if (std.mem.eql(u8, field.name, v)) { return @enumFromInt(field.value); } } return .unknown; }, else => .unknown, }; } }; pub const InputPrompt = struct { prompt: []const u8, action: []const u8, value: ?[]const u8 = null, is_password: bool = false, }; }; pub const List = struct { title: []const u8, count: usize = 0, subtitle: ?[]const u8 = null, image_key: ?[]const u8 = null, level: usize = 0, display_offset: ?i64 = null, hint: Hint = .unknown, pub const Hint = enum { unknown, action_list, pub fn jsonParseFromValue( _: std.mem.Allocator, value: std.json.Value, _: std.json.ParseOptions, ) std.json.ParseFromValueError!@This() { return switch (value) { .string => |v| { inline for (@typeInfo(@This()).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, "unknown")) { continue; } if (std.mem.eql(u8, field.name, v)) { return @enumFromInt(field.value); } } return .unknown; }, else => .unknown, }; } }; }; pub const Browse = struct { const method = "/browse"; pub const Request = struct { hierarchy: []const u8, item_key: ?[]const u8 = null, input: ?[]const u8 = null, zone_or_output_id: ?[]const u8 = null, pop_all: ?bool = null, pop_levels: ?usize = null, refresh_list: ?bool = null, /// Caller owns the returned memory pub fn encode( self: Request, allocator: std.mem.Allocator, request_id: i64, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&self, .{ .emit_null_optional_fields = false, }); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } }; pub const Response = struct { action: Action, item: ?Item = null, list: ?List = null, message: ?[]const u8 = null, is_error: ?bool = null, const Action = enum { message, none, list, replace_item, remove_item, }; pub const Error = error{ NonSuccessResponse, }; pub fn parse( allocator: std.mem.Allocator, meta: *const moo.Metadata, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(Response) { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err("Expected \"Success\" for {s} endpoint, got \"{s}\"", .{ method, meta.service }); return Error.NonSuccessResponse; } _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(Response).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } }; }; pub const Load = struct { const method = "/load"; pub const Request = struct { set_display_offset: ?i64 = null, level: ?usize = 0, offset: ?i64 = 0, count: ?usize = 0, hierarchy: []const u8, multi_session_key: ?[]const u8 = null, /// Caller owns the returned memory pub fn encode( self: Request, allocator: std.mem.Allocator, request_id: i64, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&self, .{ .emit_null_optional_fields = false, }); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } }; pub const Response = struct { items: []const Item, offset: i64 = 0, list: List, pub const Error = error{ NonSuccessResponse, }; pub fn parse( allocator: std.mem.Allocator, meta: *const moo.Metadata, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(Response) { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err("Expected \"Success\" for {s} endpoint, got \"{s}\"", .{ method, meta.service }); return Error.NonSuccessResponse; } _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(Response).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } }; }; };
-
-
-
@@ -139,5 +139,18 @@public void control(Transport.Zone zone, uint16 action) { conn.control(zone, action); } public async Browse.Result? browse(Browse.Hierarchy hierarchy, Transport.Zone? zone, Browse.Item? item, bool back) { GLib.SourceFunc callback = browse.callback; Browse.Result? result = null; new GLib.Thread<void>("browse", () => { result = conn.browse(hierarchy, zone, item, back); GLib.Idle.add((owned) callback); }); yield; return (owned) result; } } }
-