srctree

Andrew Kelley parent 65ce41ed e2e5db4b
web ui lists albums

client/main.zig added: 196, removed: 54, total 142
@@ -38,6 +38,23 @@ fn logFn(
js.log(line.ptr, line.len);
}
 
const String = Slice(u8);
 
fn Slice(T: type) type {
return packed struct(u64) {
ptr: u32,
len: u32,
 
fn init(s: []const T) @This() {
return .{
.ptr = @intFromPtr(s.ptr),
.len = s.len,
};
}
};
}
 
var db = Db.empty;
var message_buffer: std.ArrayListAlignedUnmanaged(u8, @alignOf(u32)) = .{};
 
/// Resizes the message buffer to be the correct length; returns the pointer to
@@ -48,6 +65,41 @@ export fn message_begin(len: usize) [*]u8 {
}
 
export fn message_end() void {
const header: protocol.Header = @bitCast(message_buffer.items[0..@sizeOf(protocol.Header)].*);
log.debug("got message: {any}", .{header});
const msg_bytes = message_buffer.items;
const header: protocol.Header = @bitCast(msg_bytes[0..@sizeOf(protocol.Header)].*);
 
const files_start = @sizeOf(protocol.Header);
const files_end = files_start + header.files_len * @sizeOf(Db.File);
const directories_start = files_end;
const directories_end = directories_start + header.directories_len * @sizeOf(Db.Path);
const albums_start = directories_end;
const albums_end = albums_start + header.albums_len * @sizeOf(Db.Album);
const string_bytes = msg_bytes[albums_end..][0..header.string_bytes_len];
 
const files: []const Db.File = @alignCast(std.mem.bytesAsSlice(Db.File, msg_bytes[files_start..files_end]));
const directories: []const Db.Path = @alignCast(std.mem.bytesAsSlice(Db.Path, msg_bytes[directories_start..directories_end]));
const albums: []const Db.Album = @alignCast(std.mem.bytesAsSlice(Db.Album, msg_bytes[albums_start..albums_end]));
updateDb(files, directories, albums, string_bytes) catch @panic("OOM");
}
 
fn updateDb(
files: []const Db.File,
directories: []const Db.Path,
albums: []const Db.Album,
string_bytes: []const u8,
) !void {
try db.files.appendSlice(gpa, files);
try db.directories.appendSlice(gpa, directories);
try db.string_bytes.appendSlice(gpa, string_bytes);
try db.albums.entries.resize(gpa, albums.len);
@memcpy(db.albums.entries.items(.key), albums);
try db.albums.reIndex(gpa);
}
 
export fn albums_len() usize {
return db.albums.entries.len;
}
 
export fn album_title(index: u32) String {
return String.init(db.optStringToSlice(db.albums.keys()[index].title) orelse "");
}
 
server/WebSocket.zig added: 196, removed: 54, total 142
@@ -100,7 +100,8 @@ pub const ReadSmallTextMessageError = error{
ConnectionClose,
UnexpectedOpCode,
MessageTooBig,
};
MissingMaskBit,
} || RecvError;
 
/// Reads the next message from the WebSocket stream, failing if the message does not fit
/// into `recv_buffer`.
@@ -129,7 +130,7 @@ pub fn readSmallMessage(ws: *WebSocket) ReadSmallTextMessageError![]u8 {
const payload = try recv(ws, len);
 
// The last item may contain a partial word of unused data.
const u32_payload: []u32 = std.mem.bytesAsSlice(u32, payload);
const u32_payload: []align(2) u32 = @alignCast(std.mem.bytesAsSlice(u32, payload));
for (u32_payload) |*elem| elem.* ^= mask;
 
return payload;
@@ -141,23 +142,26 @@ fn recv(ws: *WebSocket, n: usize) ![]u8 {
return result;
}
 
fn recvPeek(ws: *WebSocket, n: usize) ![]u8 {
const RecvError = std.http.Server.Request.ReadError || error{EndOfStream};
 
fn recvPeek(ws: *WebSocket, n: usize) RecvError![]u8 {
assert(n <= ws.recv_fifo.buf.len);
if (n > ws.recv_fifo.count) {
const small_buf = ws.recv_fifo.writableSlice();
const small_buf = ws.recv_fifo.writableSlice(0);
const needed = n - ws.recv_fifo.count;
const buf = if (small_buf.len >= needed) small_buf else b: {
ws.recv_fifo.realign();
break :b ws.recv_fifo.writableSlice();
break :b ws.recv_fifo.writableSlice(0);
};
try ws.reader.readNoEof(buf);
try @as(RecvError!void, @errorCast(ws.reader.readNoEof(buf)));
ws.recv_fifo.update(buf.len);
}
return ws.recv_fifo.readableSliceOfLen(n);
// TODO: improve the std lib API so this cast isn't necessary.
return @constCast(ws.recv_fifo.readableSliceOfLen(n));
}
 
fn recvReadInt(ws: *WebSocket, comptime I: type) I {
const unswapped: I = @bitCast((try recv(ws, @sizeOf(I))[0..@sizeOf(I)]).*);
fn recvReadInt(ws: *WebSocket, comptime I: type) !I {
const unswapped: I = @bitCast((try recv(ws, @sizeOf(I)))[0..@sizeOf(I)].*);
return switch (native_endian) {
.little => @byteSwap(unswapped),
.big => unswapped,
 
server/main.zig added: 196, removed: 54, total 142
@@ -85,13 +85,7 @@ pub fn main() anyerror!noreturn {
try thread_pool.init(.{ .allocator = gpa });
defer thread_pool.deinit();
 
var db: Db = .{
.files = .{},
.directories = .{},
.string_bytes = .{},
.string_table = .{},
.albums = .{},
};
var db = Db.empty;
defer db.deinit(gpa);
 
// Reserve string index 0 for an empty string.
@@ -194,7 +188,11 @@ pub const Server = struct {
};
defer send_thread.join();
 
while (true) {}
while (true) {
const msg = try ws.readSmallMessage();
_ = msg;
@panic("TODO handle message from client");
}
}
 
fn websocketSendLoop(s: *Server, ws: *WebSocket) !void {
@@ -223,7 +221,7 @@ pub const Server = struct {
 
fn scanDir(db: *Db, gpa: Allocator, db_dir: Db.Path.Index, it: *std.fs.Dir.Iterator) anyerror!void {
while (try it.next()) |entry| switch (entry.kind) {
.directory => {
.directory, .sym_link => {
const basename = try db.getOrPutString(gpa, entry.name);
var sub_dir = try it.dir.openDir(entry.name, .{ .iterate = true });
defer sub_dir.close();
@@ -335,17 +333,31 @@ fn fileMetaData(gpa: Allocator, db: *Db, file: *player.File) !FileMetaData {
const parsed_track = parseTrackTuple(trim(tag.value));
result.track_number = parsed_track.numerator;
result.track_count = parsed_track.denominator;
} else if (asciizEql(tag.key, "tracktotal") or
asciizEql(tag.key, "totaltracks"))
{
if (std.fmt.parseInt(i16, trim(tag.value), 10)) |x| {
result.track_count = x;
} else |_| {}
} else if (asciizEql(tag.key, "disc") or
asciizEql(tag.key, "TPA") or
asciizEql(tag.key, "TPOS"))
asciizEql(tag.key, "tpa") or
asciizEql(tag.key, "tpos"))
{
const parsed_disc = parseTrackTuple(trim(tag.value));
result.disc_number = parsed_disc.numerator;
result.disc_count = parsed_disc.denominator;
} else if (asciizEql(tag.key, "disctotal") or
asciizEql(tag.key, "totaldiscs"))
{
if (std.fmt.parseInt(i16, trim(tag.value), 10)) |x| {
result.disc_count = x;
} else |_| {}
} else if (asciizEql(tag.key, "date")) {
result.year = std.fmt.parseInt(i16, trim(tag.value), 10) catch -1;
} else if (asciizEql(tag.key, "title")) {
std.log.debug("pre='{s}' trimmed='{s}'", .{ tag.value, trim(tag.value) });
result.title = (try db.getOrPutString(gpa, trim(tag.value))).toOptional();
std.log.debug("saved title '{s}'", .{db.optStringToSlice(result.title).?});
} else if (asciizEql(tag.key, "artist")) {
result.artist = (try db.getOrPutString(gpa, trim(tag.value))).toOptional();
} else if (asciizEql(tag.key, "album_artist")) {
@@ -357,15 +369,14 @@ fn fileMetaData(gpa: Allocator, db: *Db, file: *player.File) !FileMetaData {
} else if (asciizEql(tag.key, "genre")) {
result.genre = (try db.getOrPutString(gpa, trim(tag.value))).toOptional();
} else if (asciizEql(tag.key, "composer") or
asciizEql(tag.key, "TCM"))
asciizEql(tag.key, "tcm"))
{
result.composer = (try db.getOrPutString(gpa, trim(tag.value))).toOptional();
} else if (asciizEql(tag.key, "TCP") or
asciizEql(tag.key, "TCMP") or
asciizEql(tag.key, "COMPILATION") or
asciizEql(tag.key, "Compilation") or
} else if (asciizEql(tag.key, "tcp") or
asciizEql(tag.key, "tcmp") or
asciizEql(tag.key, "compilation") or
asciizEql(tag.key, "cpil") or
asciizEql(tag.key, "WM/IsCompilation"))
asciizEql(tag.key, "wm/iscompilation"))
{
result.compilation = true;
} else {
@@ -375,12 +386,16 @@ fn fileMetaData(gpa: Allocator, db: *Db, file: *player.File) !FileMetaData {
return result;
}
 
/// Asserts `s` is lower case already.
fn asciizEql(tag: [*:0]const u8, s: [*:0]const u8) bool {
var p_a = tag;
var p_b = s;
while (true) {
if (p_a[0] != p_b[0]) return false;
if (p_a[0] == 0) return true;
const a_lower = std.ascii.toLower(p_a[0]);
const b_lower = std.ascii.toLower(p_b[0]);
assert(p_b[0] == b_lower);
if (a_lower != b_lower) return false;
if (a_lower == 0) return true;
p_a += 1;
p_b += 1;
}
@@ -393,12 +408,18 @@ fn trim(s: [*:0]const u8) []const u8 {
else => break,
};
var len: usize = 0;
while (true) switch (start[len]) {
0, ' ', '\r', '\n', '\t' => return start[0..len],
else => len += 1,
var last_non_space: usize = 0;
while (true) : (len += 1) switch (start[len]) {
0 => return start[0 .. last_non_space + 1],
' ', '\r', '\n', '\t' => {},
else => last_non_space = len,
};
}
 
test trim {
try std.testing.expectEqualStrings("Turn Off", trim("Turn Off "));
}
 
const TrackTuple = struct {
numerator: i16,
denominator: i16,
@@ -429,15 +450,15 @@ fn parseTrackTuple(s: []const u8) TrackTuple {
 
test parseTrackTuple {
const expectEqual = std.testing.expectEqual;
try expectEqual(@as(?i16, null), parseTrackTuple("").numerator);
try expectEqual(@as(?i16, null), parseTrackTuple("").denominator);
try expectEqual(@as(i16, -1), parseTrackTuple("").numerator);
try expectEqual(@as(i16, -1), parseTrackTuple("").denominator);
 
try expectEqual(@as(?i16, 1), parseTrackTuple("1/100").numerator);
try expectEqual(@as(?i16, 100), parseTrackTuple("1/100").denominator);
try expectEqual(@as(i16, 1), parseTrackTuple("1/100").numerator);
try expectEqual(@as(i16, 100), parseTrackTuple("1/100").denominator);
 
try expectEqual(@as(?i16, null), parseTrackTuple("/-50").numerator);
try expectEqual(@as(?i16, -50), parseTrackTuple("/-50").denominator);
try expectEqual(@as(i16, -1), parseTrackTuple("/-50").numerator);
try expectEqual(@as(i16, -50), parseTrackTuple("/-50").denominator);
 
try expectEqual(@as(?i16, 10), parseTrackTuple("10").numerator);
try expectEqual(@as(?i16, null), parseTrackTuple("10").denominator);
try expectEqual(@as(i16, 10), parseTrackTuple("10").numerator);
try expectEqual(@as(i16, -1), parseTrackTuple("10").denominator);
}
 
shared/Db.zig added: 196, removed: 54, total 142
@@ -15,6 +15,14 @@ string_table: std.HashMapUnmanaged(
std.hash_map.default_max_load_percentage,
) = .{},
 
pub const empty: Db = .{
.files = .{},
.directories = .{},
.string_bytes = .{},
.string_table = .{},
.albums = .{},
};
 
/// Points inside `string_bytes`.
pub const String = enum(u32) {
/// Zero length string.
 
www/index.html added: 196, removed: 54, total 142
@@ -5,12 +5,25 @@
<title>Groove Basin</title>
<style>
body {
background-color: black;
color: white;
background-color: black;
color: white;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<p id="status">Loading...</p>
<div id="sectLibrary" class="hidden">
<h2>Library</h2>
<select id="organize">
<option>Artist / Album / Song</option>
<option selected="selected">Album / Song</option>
</select>
<ul id="listLibrary">
</ul>
</div>
<script src="main.js"></script>
</body>
</html>
 
www/main.js added: 196, removed: 54, total 142
@@ -1,4 +1,8 @@
(function(){
const domStatus = document.getElementById("status");
const domSectLibrary = document.getElementById("sectLibrary");
const domListLibrary = document.getElementById("listLibrary");
 
let ws = null;
let wasm_exports = null;
const text_decoder = new TextDecoder();
@@ -44,12 +48,9 @@
console.log("web socket opened");
}
 
function onWebSocketClose() {
console.log("web socket closed");
}
 
function onWebSocketMessage(ev) {
wasmOnMessage(ev.data);
renderUi();
}
 
function timeoutThenCreateNew() {
@@ -57,8 +58,9 @@
ws.removeEventListener('error', timeoutThenCreateNew, false);
ws.removeEventListener('close', timeoutThenCreateNew, false);
ws.removeEventListener('open', onWebSocketOpen, false);
onWebSocketClose();
ws = null;
setTimeout(connectWebSocket, 1000);
renderUi();
}
 
function wasmOnMessage(data) {
@@ -73,4 +75,46 @@
if (len === 0) return "";
return text_decoder.decode(new Uint8Array(wasm_exports.memory.buffer, ptr, len));
}
 
function unwrapString(bigint) {
const ptr = Number(bigint & 0xffffffffn);
const len = Number(bigint >> 32n);
return decodeString(ptr, len);
}
 
function renderUi() {
domStatus.classList.add("hidden");
domSectLibrary.classList.add("hidden");
 
if (ws == null) return renderLoadingUi();
renderLibraryUi();
}
 
function renderLoadingUi() {
domStatus.classList.remove("hidden");
}
 
function renderLibraryUi() {
const albums_len = wasm_exports.albums_len();
 
resizeDomList(domListLibrary, albums_len, '<li></li>');
 
for (let i = 0; i < albums_len; i += 1) {
const dbTitle = unwrapString(wasm_exports.album_title(i));
const title = (dbTitle.length == 0) ? "[Unknown Album]" : dbTitle;
const liDom = domListLibrary.children[i];
liDom.textContent = title;
}
 
domSectLibrary.classList.remove("hidden");
}
 
function resizeDomList(listDom, desiredLen, templateHtml) {
for (let i = listDom.childElementCount; i < desiredLen; i += 1) {
listDom.insertAdjacentHTML('beforeend', templateHtml);
}
while (desiredLen < listDom.childElementCount) {
listDom.removeChild(listDom.lastChild);
}
}
})();