From 3cb132517649893392be7cb9785d568693900648 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Fri, 14 Feb 2025 16:56:40 -0700 Subject: [PATCH 01/10] init --- .gitattributes | 1 + .gitignore | 19 ++ build.zig | 36 +++ build.zig.zon | 11 + flake.lock | 78 ++++++ flake.nix | 41 ++++ src/main.zig | 280 +++++++++++++++++++++ src/xml.zig | 646 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1112 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/main.zig create mode 100644 src/xml.zig diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f27e682 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# This file is for zig-specific build artifacts. +# If you have OS-specific or editor-specific files to ignore, +# such as *.swp or .DS_Store, put those in your global +# ~/.gitignore and put this in your ~/.gitconfig: +# +# [core] +# excludesfile = ~/.gitignore +# +# Cheers! +# -andrewrk + +.zig-cache/ +zig-cache/ +zig-out/ +/release/ +/debug/ +/build/ +/build-*/ +/docgen_tmp/ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..31c3f52 --- /dev/null +++ b/build.zig @@ -0,0 +1,36 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "streamboy", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = exe_mod, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..e2f4d43 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,11 @@ +.{ + .name = "streamboy", + .version = "0.0.0", + .minimum_zig_version = "0.14.0", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2cdb40e --- /dev/null +++ b/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1736817698, + "narHash": "sha256-1m+JP9RUsbeLVv/tF1DX3Ew9Vl/fatXnlh/g5k3jcSk=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "2b1fca3296ddd1602d2c4f104a4050e006f4b0cb", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "zig2nix": "zig2nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1739496552, + "narHash": "sha256-if34rjhH/CXZQTnAl629tVdz/mAx/fifjTPRPQsJ1tg=", + "owner": "Cloudef", + "repo": "zig2nix", + "rev": "0dae566efe9a0ed18c07b76a5ed8ff2c546bdd56", + "type": "github" + }, + "original": { + "owner": "Cloudef", + "repo": "zig2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5b36224 --- /dev/null +++ b/flake.nix @@ -0,0 +1,41 @@ +{ + inputs = { + zig2nix.url = "github:Cloudef/zig2nix"; + }; + + outputs = { zig2nix, ... }: let + flake-utils = zig2nix.inputs.flake-utils; + in (flake-utils.lib.eachDefaultSystem (system: let + env = zig2nix.outputs.zig-env.${system} { zig = zig2nix.outputs.packages.${system}.zig.master.bin; }; + system-triple = env.lib.zigTripleFromString system; + in with builtins; with env.lib; with env.pkgs.lib; rec { + packages.target = genAttrs allTargetTriples (target: env.packageForTarget target ({ + src = cleanSource ./.; + + nativeBuildInputs = with env.pkgs; []; + buildInputs = with env.pkgsForTarget target; []; + + zigPreferMusl = true; + zigDisableWrap = true; + })); + + packages.default = packages.target.${system-triple}.override { + zigPreferMusl = false; + zigDisableWrap = false; + }; + + apps.bundle.default = apps.bundle.target.${system-triple}; + + apps.default = env.app [] "zig build run -- \"$@\""; + apps.build = env.app [] "zig build \"$@\""; + apps.test = env.app [] "zig build test -- \"$@\""; + apps.docs = env.app [] "zig build docs -- \"$@\""; + apps.deps = env.showExternalDeps; + + apps.zon2json = env.app [env.zon2json] "zon2json \"$@\""; + apps.zon2json-lock = env.app [env.zon2json-lock] "zon2json-lock \"$@\""; + apps.zon2nix = env.app [env.zon2nix] "zon2nix \"$@\""; + + devShells.default = env.mkShell {}; + })); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..40e22ff --- /dev/null +++ b/src/main.zig @@ -0,0 +1,280 @@ +const std = @import("std"); +const xml = @import("./xml.zig"); + +// Music Player: +// - Launch VLC and control it via HTTP interface +// - Play music randomly from a VLC playlist +// - Allow media control (play pause, next, prev) +// - Expose OBS Browser Source to display current track information. +// + Add and remove songs from playlist +// + Allow voting on songs (web interface? chat bot?) +// +// Chat bot: +// - Support Twitch chat +// + Support YouTube +// +// A - means immediately, a + means eventually. + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // var vlc = std.process.Child.init(&[_][]const u8{ + // "vlc", + // "--intf", + // "http", + // "--http-host", + // "localhost", + // "--http-password", + // "1234", + // }, allocator); + // try vlc.spawn(); + + // const buf = try allocator.alloc(u8, 1024 * 32); + // defer allocator.free(buf); + + var tz_file = try std.fs.openFileAbsolute("/etc/localtime", .{}); + defer tz_file.close(); + var tz = try std.tz.Tz.parse(allocator, tz_file.reader()); + defer tz.deinit(); + + while (true) : (std.time.sleep(1_000_000_000)) { // sleep 500ms + try updateTime(tz); + try updateStatus(allocator); + scroll += 1; + } + // ♪ Red Soul (Super Metroid) - Nostalvania, The OC Jazz Collective + + // try vlc.kill(); +} + +const base64_encoder = std.base64.standard.Encoder; + +const topic = "Something Fun For Everyone With Streamboy!!!!!!!!"; + +var scroll: usize = 0; + +fn updateStatus(allocator: std.mem.Allocator) !void { + var stream_info_file = try std.fs.createFileAbsolute("/tmp/streaminfo", .{ .truncate = true }); + defer stream_info_file.close(); + + var song_info = try getSongInfo(allocator); + defer song_info.deinit(allocator); + + const string = try std.fmt.allocPrint(allocator, "{s} | ♪ {s} - {s} | ", .{ + topic, + song_info.title orelse "Unknown Title", + song_info.artist orelse "Unknown Artist", + }); + defer allocator.free(string); + + if (scroll > string.len) scroll = 0; + if (scroll == 0) try stream_info_file.writeAll(string) else { + for (string[scroll..]) |char| try stream_info_file.writer().writeByte(char); + for (string[0..scroll]) |char| try stream_info_file.writer().writeByte(char); + } +} + +const SongInfo = struct { + title: ?[]const u8, + album: ?[]const u8, + artist: ?[]const u8, + + pub fn deinit(self: *SongInfo, allocator: std.mem.Allocator) void { + if (self.title) |b| allocator.free(b); + if (self.album) |b| allocator.free(b); + if (self.artist) |b| allocator.free(b); + } +}; + +fn getSongInfo(allocator: std.mem.Allocator) !SongInfo { + var http = std.http.Client{ .allocator = allocator }; + defer http.deinit(); + + const userpass = try std.fmt.allocPrint(allocator, ":{s}", .{"1234"}); + defer allocator.free(userpass); + + const base64_userpass = try allocator.alloc(u8, base64_encoder.calcSize(userpass.len)); + defer allocator.free(base64_userpass); + + const final_userpass = try std.fmt.allocPrint(allocator, "Basic {s}", .{base64_encoder.encode(base64_userpass, userpass)}); + defer allocator.free(final_userpass); + + var response = std.ArrayList(u8).init(allocator); + defer response.deinit(); + + const result = try http.fetch(.{ + .location = .{ .url = "http://localhost:8080/requests/status.xml" }, + .headers = .{ .authorization = .{ .override = final_userpass } }, + .response_storage = .{ .dynamic = &response }, + }); + + std.debug.print("{any}\n{s}\n", .{ result, response.items }); + + const document = try xml.parse(allocator, response.items); + defer document.deinit(); + + var title: ?[]const u8 = null; + var album: ?[]const u8 = null; + var artist: ?[]const u8 = null; + + if (document.root.findChildByTag("information")) |information| { + var categories_it = information.findChildrenByTag("category"); + while (categories_it.next()) |category| { + if (std.mem.eql(u8, category.getAttribute("name").?, "meta")) { + var info_it = category.findChildrenByTag("info"); + while (info_it.next()) |info| { + const info_name = info.getAttribute("name").?; + if (std.mem.eql(u8, info_name, "title")) + title = try processHtmlString(allocator, info.children[0].char_data) + else if (std.mem.eql(u8, info_name, "album")) + album = try processHtmlString(allocator, info.children[0].char_data) + else if (std.mem.eql(u8, info_name, "artist")) + artist = try processHtmlString(allocator, info.children[0].char_data); + } + } + } + } + + // var new_title: ?[]const u8 = null; + // if (title) |t| { + // std.debug.print("{s}\n", .{t}); + + // // if (std.mem.indexOf(u8, t, "&#")) |amp| { + // // if (std.mem.indexOfScalarPos(u8, t, amp, ';')) |semi| { + // // const int = try std.fmt.parseInt(u8, t[amp + 2 .. semi], 10); + // // const new = try allocator.alloc(u8, std.mem.replacementSize(u8, t, t[amp .. semi + 1], &[1]u8{int})); + // // errdefer allocator.free(new); + // // _ = std.mem.replace(u8, t, t[amp .. semi + 1], &[1]u8{int}, new); + // // new_title = new; + // // } + // // } + // } + + // if (title) |t| title = try allocator.dupe(u8, t); + // errdefer if (title) |t| allocator.free(t); + // if (album) |a| album = try allocator.dupe(u8, a); + // errdefer if (album) |a| allocator.free(a); + // if (artist) |a| artist = try allocator.dupe(u8, a); + // errdefer if (artist) |a| allocator.free(a); + + return .{ + .title = title, + .album = album, + .artist = artist, + }; +} + +fn processHtmlString(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { + var new: []u8 = try allocator.dupe(u8, string); + errdefer allocator.free(new); + while (true) { + if (std.mem.indexOf(u8, new, "&#")) |amp| { + if (std.mem.indexOfScalarPos(u8, new, amp, ';')) |semi| { + const int = try std.fmt.parseInt(u8, new[amp + 2 .. semi], 10); + const nnew = try allocator.alloc(u8, std.mem.replacementSize(u8, new, new[amp .. semi + 1], &[1]u8{int})); + _ = std.mem.replace(u8, new, new[amp .. semi + 1], &[1]u8{int}, nnew); + allocator.free(new); + new = nnew; + } + } else break; + } + std.debug.print("{s}\n", .{new}); + return new; +} + +fn updateTime(tz: std.Tz) !void { + const original_timestamp = std.time.timestamp(); + + var timetype: *std.tz.Timetype = undefined; + for (tz.transitions, 0..) |trans, i| + if (trans.ts >= original_timestamp) { + timetype = tz.transitions[i - 1].timetype; + break; + }; + + const timestamp = std.time.timestamp() + timetype.offset + std.time.s_per_day; + + const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(timestamp) }; + const day_seconds = epoch_seconds.getDaySeconds(); + const epoch_day = epoch_seconds.getEpochDay(); + const year_day = epoch_day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + + const ampm = getAmPm(day_seconds); + + var file = try std.fs.createFileAbsolute("/tmp/time", .{ .truncate = true }); + defer file.close(); + + try file.writer().print("{s}, {s} {d} {d}, {d:0>2}:{d:0>2}:{d:0>2} {s}", .{ + getDayOfWeekName(getDayOfWeek(epoch_day)), + getMonthName(month_day.month), + month_day.day_index, + year_day.year, + ampm.hour, + day_seconds.getMinutesIntoHour(), + day_seconds.getSecondsIntoMinute(), + if (ampm.is_pm) "PM" else "AM", + }); +} + +// example format +// Friday 14 February 2025 10:32:48 AM + +fn getMonthName(month: std.time.epoch.Month) []const u8 { + return switch (month) { + .jan => "January", + .feb => "February", + .mar => "March", + .apr => "April", + .may => "May", + .jun => "June", + .jul => "July", + .aug => "August", + .sep => "September", + .oct => "October", + .nov => "November", + .dec => "December", + }; +} + +// Jan 1 1970 was a Thursday, so we make that 1 +const DayOfWeek = enum(u3) { + wed = 0, + thu, + fri, + sat, + sun, + mon, + tue, +}; + +fn getDayOfWeek(epoch_day: std.time.epoch.EpochDay) DayOfWeek { + return @enumFromInt(@mod(epoch_day.day, 7)); +} + +fn getDayOfWeekName(day_of_week: DayOfWeek) []const u8 { + return switch (day_of_week) { + .sun => "Sunday", + .mon => "Monday", + .tue => "Tuesday", + .wed => "Wednesday", + .thu => "Thursday", + .fri => "Friday", + .sat => "Saturday", + }; +} + +const AmPm = struct { + hour: u4, + is_pm: bool, +}; + +fn getAmPm(day_seconds: std.time.epoch.DaySeconds) AmPm { + const hour = day_seconds.getHoursIntoDay(); + return .{ + .hour = if (hour < 13) @intCast(hour) else @intCast(hour - 12), + .is_pm = hour != 0 and hour > 11, + }; +} diff --git a/src/xml.zig b/src/xml.zig new file mode 100644 index 0000000..94f8719 --- /dev/null +++ b/src/xml.zig @@ -0,0 +1,646 @@ +// Copyright © 2020-2022 Robin Voetter +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +const std = @import("std"); +const mem = std.mem; +const testing = std.testing; +const Allocator = mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +pub const Attribute = struct { + name: []const u8, + value: []const u8, +}; + +pub const Content = union(enum) { + char_data: []const u8, + comment: []const u8, + element: *Element, +}; + +pub const Element = struct { + tag: []const u8, + attributes: []Attribute = &.{}, + children: []Content = &.{}, + + pub fn getAttribute(self: Element, attrib_name: []const u8) ?[]const u8 { + for (self.attributes) |child| { + if (mem.eql(u8, child.name, attrib_name)) { + return child.value; + } + } + + return null; + } + + pub fn getCharData(self: Element, child_tag: []const u8) ?[]const u8 { + const child = self.findChildByTag(child_tag) orelse return null; + if (child.children.len != 1) { + return null; + } + + return switch (child.children[0]) { + .char_data => |char_data| char_data, + else => null, + }; + } + + pub fn iterator(self: Element) ChildIterator { + return .{ + .items = self.children, + .i = 0, + }; + } + + pub fn elements(self: Element) ChildElementIterator { + return .{ + .inner = self.iterator(), + }; + } + + pub fn findChildByTag(self: Element, tag: []const u8) ?*Element { + var it = self.findChildrenByTag(tag); + return it.next(); + } + + pub fn findChildrenByTag(self: Element, tag: []const u8) FindChildrenByTagIterator { + return .{ + .inner = self.elements(), + .tag = tag, + }; + } + + pub const ChildIterator = struct { + items: []Content, + i: usize, + + pub fn next(self: *ChildIterator) ?*Content { + if (self.i < self.items.len) { + self.i += 1; + return &self.items[self.i - 1]; + } + + return null; + } + }; + + pub const ChildElementIterator = struct { + inner: ChildIterator, + + pub fn next(self: *ChildElementIterator) ?*Element { + while (self.inner.next()) |child| { + if (child.* != .element) { + continue; + } + + return child.*.element; + } + + return null; + } + }; + + pub const FindChildrenByTagIterator = struct { + inner: ChildElementIterator, + tag: []const u8, + + pub fn next(self: *FindChildrenByTagIterator) ?*Element { + while (self.inner.next()) |child| { + if (!mem.eql(u8, child.tag, self.tag)) { + continue; + } + + return child; + } + + return null; + } + }; +}; + +pub const Document = struct { + arena: ArenaAllocator, + xml_decl: ?*Element, + root: *Element, + + pub fn deinit(self: Document) void { + var arena = self.arena; // Copy to stack so self can be taken by value. + arena.deinit(); + } +}; + +const Parser = struct { + source: []const u8, + offset: usize, + line: usize, + column: usize, + + fn init(source: []const u8) Parser { + return .{ + .source = source, + .offset = 0, + .line = 0, + .column = 0, + }; + } + + fn peek(self: *Parser) ?u8 { + return if (self.offset < self.source.len) self.source[self.offset] else null; + } + + fn consume(self: *Parser) !u8 { + if (self.offset < self.source.len) { + return self.consumeNoEof(); + } + + return error.UnexpectedEof; + } + + fn consumeNoEof(self: *Parser) u8 { + std.debug.assert(self.offset < self.source.len); + const c = self.source[self.offset]; + self.offset += 1; + + if (c == '\n') { + self.line += 1; + self.column = 0; + } else { + self.column += 1; + } + + return c; + } + + fn eat(self: *Parser, char: u8) bool { + self.expect(char) catch return false; + return true; + } + + fn expect(self: *Parser, expected: u8) !void { + if (self.peek()) |actual| { + if (expected != actual) { + return error.UnexpectedCharacter; + } + + _ = self.consumeNoEof(); + return; + } + + return error.UnexpectedEof; + } + + fn eatStr(self: *Parser, text: []const u8) bool { + self.expectStr(text) catch return false; + return true; + } + + fn expectStr(self: *Parser, text: []const u8) !void { + if (self.source.len < self.offset + text.len) { + return error.UnexpectedEof; + } else if (mem.startsWith(u8, self.source[self.offset..], text)) { + var i: usize = 0; + while (i < text.len) : (i += 1) { + _ = self.consumeNoEof(); + } + + return; + } + + return error.UnexpectedCharacter; + } + + fn eatWs(self: *Parser) bool { + var ws = false; + + while (self.peek()) |ch| { + switch (ch) { + ' ', '\t', '\n', '\r' => { + ws = true; + _ = self.consumeNoEof(); + }, + else => break, + } + } + + return ws; + } + + fn expectWs(self: *Parser) !void { + if (!self.eatWs()) return error.UnexpectedCharacter; + } + + fn currentLine(self: Parser) []const u8 { + var begin: usize = 0; + if (mem.lastIndexOfScalar(u8, self.source[0..self.offset], '\n')) |prev_nl| { + begin = prev_nl + 1; + } + + const end = mem.indexOfScalarPos(u8, self.source, self.offset, '\n') orelse self.source.len; + return self.source[begin..end]; + } +}; + +test "xml: Parser" { + { + var parser = Parser.init("I like pythons"); + try testing.expectEqual(@as(?u8, 'I'), parser.peek()); + try testing.expectEqual(@as(u8, 'I'), parser.consumeNoEof()); + try testing.expectEqual(@as(?u8, ' '), parser.peek()); + try testing.expectEqual(@as(u8, ' '), try parser.consume()); + + try testing.expect(parser.eat('l')); + try testing.expectEqual(@as(?u8, 'i'), parser.peek()); + try testing.expectEqual(false, parser.eat('a')); + try testing.expectEqual(@as(?u8, 'i'), parser.peek()); + + try parser.expect('i'); + try testing.expectEqual(@as(?u8, 'k'), parser.peek()); + try testing.expectError(error.UnexpectedCharacter, parser.expect('a')); + try testing.expectEqual(@as(?u8, 'k'), parser.peek()); + + try testing.expect(parser.eatStr("ke")); + try testing.expectEqual(@as(?u8, ' '), parser.peek()); + + try testing.expect(parser.eatWs()); + try testing.expectEqual(@as(?u8, 'p'), parser.peek()); + try testing.expectEqual(false, parser.eatWs()); + try testing.expectEqual(@as(?u8, 'p'), parser.peek()); + + try testing.expectEqual(false, parser.eatStr("aaaaaaaaa")); + try testing.expectEqual(@as(?u8, 'p'), parser.peek()); + + try testing.expectError(error.UnexpectedEof, parser.expectStr("aaaaaaaaa")); + try testing.expectEqual(@as(?u8, 'p'), parser.peek()); + try testing.expectError(error.UnexpectedCharacter, parser.expectStr("pytn")); + try testing.expectEqual(@as(?u8, 'p'), parser.peek()); + try parser.expectStr("python"); + try testing.expectEqual(@as(?u8, 's'), parser.peek()); + } + + { + var parser = Parser.init(""); + try testing.expectEqual(parser.peek(), null); + try testing.expectError(error.UnexpectedEof, parser.consume()); + try testing.expectEqual(parser.eat('p'), false); + try testing.expectError(error.UnexpectedEof, parser.expect('p')); + } +} + +pub const ParseError = error{ + IllegalCharacter, + UnexpectedEof, + UnexpectedCharacter, + UnclosedValue, + UnclosedComment, + InvalidName, + InvalidEntity, + InvalidStandaloneValue, + NonMatchingClosingTag, + InvalidDocument, + OutOfMemory, +}; + +pub fn parse(backing_allocator: Allocator, source: []const u8) !Document { + var parser = Parser.init(source); + return try parseDocument(&parser, backing_allocator); +} + +fn parseDocument(parser: *Parser, backing_allocator: Allocator) !Document { + var doc = Document{ + .arena = ArenaAllocator.init(backing_allocator), + .xml_decl = null, + .root = undefined, + }; + + errdefer doc.deinit(); + + const allocator = doc.arena.allocator(); + + try skipComments(parser, allocator); + + doc.xml_decl = try parseElement(parser, allocator, .xml_decl); + _ = parser.eatWs(); + try skipComments(parser, allocator); + + doc.root = (try parseElement(parser, allocator, .element)) orelse return error.InvalidDocument; + _ = parser.eatWs(); + try skipComments(parser, allocator); + + if (parser.peek() != null) return error.InvalidDocument; + + return doc; +} + +fn parseAttrValue(parser: *Parser, alloc: Allocator) ![]const u8 { + const quote = try parser.consume(); + if (quote != '"' and quote != '\'') return error.UnexpectedCharacter; + + const begin = parser.offset; + + while (true) { + const c = parser.consume() catch return error.UnclosedValue; + if (c == quote) break; + } + + const end = parser.offset - 1; + + return try unescape(alloc, parser.source[begin..end]); +} + +fn parseEqAttrValue(parser: *Parser, alloc: Allocator) ![]const u8 { + _ = parser.eatWs(); + try parser.expect('='); + _ = parser.eatWs(); + + return try parseAttrValue(parser, alloc); +} + +fn parseNameNoDupe(parser: *Parser) ![]const u8 { + // XML's spec on names is very long, so to make this easier + // we just take any character that is not special and not whitespace + const begin = parser.offset; + + while (parser.peek()) |ch| { + switch (ch) { + ' ', '\t', '\n', '\r' => break, + '&', '"', '\'', '<', '>', '?', '=', '/' => break, + else => _ = parser.consumeNoEof(), + } + } + + const end = parser.offset; + if (begin == end) return error.InvalidName; + + return parser.source[begin..end]; +} + +fn parseCharData(parser: *Parser, alloc: Allocator) !?[]const u8 { + const begin = parser.offset; + + while (parser.peek()) |ch| { + switch (ch) { + '<' => break, + else => _ = parser.consumeNoEof(), + } + } + + const end = parser.offset; + if (begin == end) return null; + + return try unescape(alloc, parser.source[begin..end]); +} + +fn parseContent(parser: *Parser, alloc: Allocator) ParseError!Content { + if (try parseCharData(parser, alloc)) |cd| { + return Content{ .char_data = cd }; + } else if (try parseComment(parser, alloc)) |comment| { + return Content{ .comment = comment }; + } else if (try parseElement(parser, alloc, .element)) |elem| { + return Content{ .element = elem }; + } else { + return error.UnexpectedCharacter; + } +} + +fn parseAttr(parser: *Parser, alloc: Allocator) !?Attribute { + const name = parseNameNoDupe(parser) catch return null; + _ = parser.eatWs(); + try parser.expect('='); + _ = parser.eatWs(); + const value = try parseAttrValue(parser, alloc); + + const attr = Attribute{ + .name = try alloc.dupe(u8, name), + .value = value, + }; + return attr; +} + +const ElementKind = enum { + xml_decl, + element, +}; + +fn parseElement(parser: *Parser, alloc: Allocator, comptime kind: ElementKind) !?*Element { + const start = parser.offset; + + const tag = switch (kind) { + .xml_decl => blk: { + if (!parser.eatStr(" blk: { + if (!parser.eat('<')) return null; + const tag = parseNameNoDupe(parser) catch { + parser.offset = start; + return null; + }; + break :blk tag; + }, + }; + + var attributes = std.ArrayList(Attribute).init(alloc); + defer attributes.deinit(); + + var children = std.ArrayList(Content).init(alloc); + defer children.deinit(); + + while (parser.eatWs()) { + const attr = (try parseAttr(parser, alloc)) orelse break; + try attributes.append(attr); + } + + switch (kind) { + .xml_decl => try parser.expectStr("?>"), + .element => { + if (!parser.eatStr("/>")) { + try parser.expect('>'); + + while (true) { + if (parser.peek() == null) { + return error.UnexpectedEof; + } else if (parser.eatStr("'); + } + }, + } + + const element = try alloc.create(Element); + element.* = .{ + .tag = try alloc.dupe(u8, tag), + .attributes = try attributes.toOwnedSlice(), + .children = try children.toOwnedSlice(), + }; + return element; +} + +test "xml: parseElement" { + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var parser = Parser.init("<= a='b'/>"); + try testing.expectEqual(@as(?*Element, null), try parseElement(&parser, alloc, .element)); + try testing.expectEqual(@as(?u8, '<'), parser.peek()); + } + + { + var parser = Parser.init(""); + const elem = try parseElement(&parser, alloc, .element); + try testing.expectEqualSlices(u8, elem.?.tag, "python"); + + const size_attr = elem.?.attributes[0]; + try testing.expectEqualSlices(u8, size_attr.name, "size"); + try testing.expectEqualSlices(u8, size_attr.value, "15"); + + const color_attr = elem.?.attributes[1]; + try testing.expectEqualSlices(u8, color_attr.name, "color"); + try testing.expectEqualSlices(u8, color_attr.value, "green"); + } + + { + var parser = Parser.init("test"); + const elem = try parseElement(&parser, alloc, .element); + try testing.expectEqualSlices(u8, elem.?.tag, "python"); + try testing.expectEqualSlices(u8, elem.?.children[0].char_data, "test"); + } + + { + var parser = Parser.init("bdf"); + const elem = try parseElement(&parser, alloc, .element); + try testing.expectEqualSlices(u8, elem.?.tag, "a"); + try testing.expectEqualSlices(u8, elem.?.children[0].char_data, "b"); + try testing.expectEqualSlices(u8, elem.?.children[1].element.tag, "c"); + try testing.expectEqualSlices(u8, elem.?.children[2].char_data, "d"); + try testing.expectEqualSlices(u8, elem.?.children[3].element.tag, "e"); + try testing.expectEqualSlices(u8, elem.?.children[4].char_data, "f"); + try testing.expectEqualSlices(u8, elem.?.children[5].comment, "g"); + } +} + +test "xml: parse prolog" { + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + { + var parser = Parser.init(""); + try testing.expectEqual(@as(?*Element, null), try parseElement(&parser, a, .xml_decl)); + try testing.expectEqual(@as(?u8, '<'), parser.peek()); + } + + { + var parser = Parser.init(""); + const decl = try parseElement(&parser, a, .xml_decl); + try testing.expectEqualSlices(u8, "aa", decl.?.getAttribute("version").?); + try testing.expectEqual(@as(?[]const u8, null), decl.?.getAttribute("encoding")); + try testing.expectEqual(@as(?[]const u8, null), decl.?.getAttribute("standalone")); + } + + { + var parser = Parser.init(""); + const decl = try parseElement(&parser, a, .xml_decl); + try testing.expectEqualSlices(u8, "ccc", decl.?.getAttribute("version").?); + try testing.expectEqualSlices(u8, "bbb", decl.?.getAttribute("encoding").?); + try testing.expectEqualSlices(u8, "yes", decl.?.getAttribute("standalone").?); + } +} + +fn skipComments(parser: *Parser, alloc: Allocator) !void { + while ((try parseComment(parser, alloc)) != null) { + _ = parser.eatWs(); + } +} + +fn parseComment(parser: *Parser, alloc: Allocator) !?[]const u8 { + if (!parser.eatStr("")) { + _ = parser.consume() catch return error.UnclosedComment; + } + + const end = parser.offset - "-->".len; + return try alloc.dupe(u8, parser.source[begin..end]); +} + +fn unescapeEntity(text: []const u8) !u8 { + const EntitySubstition = struct { text: []const u8, replacement: u8 }; + + const entities = [_]EntitySubstition{ + .{ .text = "<", .replacement = '<' }, + .{ .text = ">", .replacement = '>' }, + .{ .text = "&", .replacement = '&' }, + .{ .text = "'", .replacement = '\'' }, + .{ .text = """, .replacement = '"' }, + }; + + for (entities) |entity| { + if (mem.eql(u8, text, entity.text)) return entity.replacement; + } + + return error.InvalidEntity; +} + +fn unescape(arena: Allocator, text: []const u8) ![]const u8 { + const unescaped = try arena.alloc(u8, text.len); + + var j: usize = 0; + var i: usize = 0; + while (i < text.len) : (j += 1) { + if (text[i] == '&') { + const entity_end = 1 + (mem.indexOfScalarPos(u8, text, i, ';') orelse return error.InvalidEntity); + unescaped[j] = try unescapeEntity(text[i..entity_end]); + i = entity_end; + } else { + unescaped[j] = text[i]; + i += 1; + } + } + + return unescaped[0..j]; +} + +test "xml: unescape" { + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + try testing.expectEqualSlices(u8, "test", try unescape(a, "test")); + try testing.expectEqualSlices(u8, "ad\"e'f<", try unescape(a, "a<b&c>d"e'f<")); + try testing.expectError(error.InvalidEntity, unescape(a, "python&")); + try testing.expectError(error.InvalidEntity, unescape(a, "python&&")); + try testing.expectError(error.InvalidEntity, unescape(a, "python&test;")); + try testing.expectError(error.InvalidEntity, unescape(a, "python&boa")); +} + +test "xml: top level comments" { + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const doc = try parse(a, ""); + try testing.expectEqualSlices(u8, "python", doc.root.tag); +} From a7ca2f9377b00d2ab734e1947d44361356759bfb Mon Sep 17 00:00:00 2001 From: Jeeves Date: Fri, 14 Feb 2025 17:05:43 -0700 Subject: [PATCH 02/10] clean up and add README --- README.md | 6 ++++++ src/main.zig | 31 +------------------------------ 2 files changed, 7 insertions(+), 30 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e5665d --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Streamboy! + +Jeeves' personal streaming toolkit. + +{insert stereotypical message of This Is A Mess Not Designed For Others} +so Good Luck Have Fun :) diff --git a/src/main.zig b/src/main.zig index 40e22ff..7eea06a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -31,9 +31,6 @@ pub fn main() !void { // }, allocator); // try vlc.spawn(); - // const buf = try allocator.alloc(u8, 1024 * 32); - // defer allocator.free(buf); - var tz_file = try std.fs.openFileAbsolute("/etc/localtime", .{}); defer tz_file.close(); var tz = try std.tz.Tz.parse(allocator, tz_file.reader()); @@ -44,14 +41,13 @@ pub fn main() !void { try updateStatus(allocator); scroll += 1; } - // ♪ Red Soul (Super Metroid) - Nostalvania, The OC Jazz Collective // try vlc.kill(); } const base64_encoder = std.base64.standard.Encoder; -const topic = "Something Fun For Everyone With Streamboy!!!!!!!!"; +const topic = "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy"; var scroll: usize = 0; @@ -137,28 +133,6 @@ fn getSongInfo(allocator: std.mem.Allocator) !SongInfo { } } - // var new_title: ?[]const u8 = null; - // if (title) |t| { - // std.debug.print("{s}\n", .{t}); - - // // if (std.mem.indexOf(u8, t, "&#")) |amp| { - // // if (std.mem.indexOfScalarPos(u8, t, amp, ';')) |semi| { - // // const int = try std.fmt.parseInt(u8, t[amp + 2 .. semi], 10); - // // const new = try allocator.alloc(u8, std.mem.replacementSize(u8, t, t[amp .. semi + 1], &[1]u8{int})); - // // errdefer allocator.free(new); - // // _ = std.mem.replace(u8, t, t[amp .. semi + 1], &[1]u8{int}, new); - // // new_title = new; - // // } - // // } - // } - - // if (title) |t| title = try allocator.dupe(u8, t); - // errdefer if (title) |t| allocator.free(t); - // if (album) |a| album = try allocator.dupe(u8, a); - // errdefer if (album) |a| allocator.free(a); - // if (artist) |a| artist = try allocator.dupe(u8, a); - // errdefer if (artist) |a| allocator.free(a); - return .{ .title = title, .album = album, @@ -219,9 +193,6 @@ fn updateTime(tz: std.Tz) !void { }); } -// example format -// Friday 14 February 2025 10:32:48 AM - fn getMonthName(month: std.time.epoch.Month) []const u8 { return switch (month) { .jan => "January", From 6ce0d30b4c85827f9c81444dbcc46549d55ec0b8 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Sat, 15 Feb 2025 06:14:29 -0700 Subject: [PATCH 03/10] clean up VLC-related code --- src/main.zig | 221 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 137 insertions(+), 84 deletions(-) diff --git a/src/main.zig b/src/main.zig index 7eea06a..66b6bc3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const xml = @import("./xml.zig"); // Music Player: // - Launch VLC and control it via HTTP interface @@ -20,16 +19,8 @@ pub fn main() !void { defer _ = gpa.deinit(); const allocator = gpa.allocator(); - // var vlc = std.process.Child.init(&[_][]const u8{ - // "vlc", - // "--intf", - // "http", - // "--http-host", - // "localhost", - // "--http-password", - // "1234", - // }, allocator); - // try vlc.spawn(); + var vlc = try VLC.init(allocator, try std.Uri.parse("http://localhost:8080")); + defer vlc.deinit(); var tz_file = try std.fs.openFileAbsolute("/etc/localtime", .{}); defer tz_file.close(); @@ -38,25 +29,24 @@ pub fn main() !void { while (true) : (std.time.sleep(1_000_000_000)) { // sleep 500ms try updateTime(tz); - try updateStatus(allocator); + try updateStatus(allocator, &vlc); scroll += 1; } - - // try vlc.kill(); } const base64_encoder = std.base64.standard.Encoder; +// TODO make the URL something short like jeevio.xyz/streamboy const topic = "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy"; var scroll: usize = 0; -fn updateStatus(allocator: std.mem.Allocator) !void { +fn updateStatus(allocator: std.mem.Allocator, vlc: *VLC) !void { var stream_info_file = try std.fs.createFileAbsolute("/tmp/streaminfo", .{ .truncate = true }); defer stream_info_file.close(); - var song_info = try getSongInfo(allocator); - defer song_info.deinit(allocator); + var song_info = try vlc.getSongInfo(); + defer song_info.deinit(); const string = try std.fmt.allocPrint(allocator, "{s} | ♪ {s} - {s} | ", .{ topic, @@ -72,91 +62,154 @@ fn updateStatus(allocator: std.mem.Allocator) !void { } } -const SongInfo = struct { - title: ?[]const u8, - album: ?[]const u8, - artist: ?[]const u8, +pub const VLC = struct { + allocator: std.mem.Allocator, + http: std.http.Client, + base_uri: std.Uri, + authorization: []u8, - pub fn deinit(self: *SongInfo, allocator: std.mem.Allocator) void { - if (self.title) |b| allocator.free(b); - if (self.album) |b| allocator.free(b); - if (self.artist) |b| allocator.free(b); + pub fn init(allocator: std.mem.Allocator, base_uri: std.Uri) !VLC { + const userpass = try std.fmt.allocPrint(allocator, ":{s}", .{"1234"}); + defer allocator.free(userpass); + + const base64_userpass = try allocator.alloc(u8, base64_encoder.calcSize(userpass.len)); + defer allocator.free(base64_userpass); + + const authorization = try std.fmt.allocPrint(allocator, "Basic {s}", .{base64_encoder.encode(base64_userpass, userpass)}); + errdefer allocator.free(authorization); + + // var vlc = std.process.Child.init(&[_][]const u8{ + // "vlc", + // "--intf", + // "http", + // "--http-host", + // "localhost", + // "--http-password", + // "1234", + // }, allocator); + // try vlc.spawn(); + + return .{ + .allocator = allocator, + .http = std.http.Client{ .allocator = allocator }, + .base_uri = base_uri, + .authorization = authorization, + }; } -}; -fn getSongInfo(allocator: std.mem.Allocator) !SongInfo { - var http = std.http.Client{ .allocator = allocator }; - defer http.deinit(); + pub fn deinit(self: *VLC) void { + self.http.deinit(); + self.allocator.free(self.authorization); + // try vlc.kill(); + } - const userpass = try std.fmt.allocPrint(allocator, ":{s}", .{"1234"}); - defer allocator.free(userpass); + fn request(self: *VLC, uri: std.Uri) !Response { + var combined_uri = self.base_uri; + combined_uri.path = uri.path; + combined_uri.query = uri.query; + combined_uri.fragment = uri.fragment; - const base64_userpass = try allocator.alloc(u8, base64_encoder.calcSize(userpass.len)); - defer allocator.free(base64_userpass); + var response = std.ArrayList(u8).init(self.allocator); + defer response.deinit(); - const final_userpass = try std.fmt.allocPrint(allocator, "Basic {s}", .{base64_encoder.encode(base64_userpass, userpass)}); - defer allocator.free(final_userpass); + const result = try self.http.fetch(.{ + .location = .{ .uri = combined_uri }, + .headers = .{ .authorization = .{ .override = self.authorization } }, + .response_storage = .{ .dynamic = &response }, + }); - var response = std.ArrayList(u8).init(allocator); - defer response.deinit(); + // std.debug.print("{any}\n{s}\n", .{ result, response.items }); - const result = try http.fetch(.{ - .location = .{ .url = "http://localhost:8080/requests/status.xml" }, - .headers = .{ .authorization = .{ .override = final_userpass } }, - .response_storage = .{ .dynamic = &response }, - }); + if (result.status != .ok) return error.HttpRequestFailed; - std.debug.print("{any}\n{s}\n", .{ result, response.items }); + const buffer = try response.toOwnedSlice(); + return .{ + .buffer = buffer, + .document = try xml.parse(self.allocator, buffer), + }; + } - const document = try xml.parse(allocator, response.items); - defer document.deinit(); + const Response = struct { + buffer: []u8, + document: xml.Document, - var title: ?[]const u8 = null; - var album: ?[]const u8 = null; - var artist: ?[]const u8 = null; + pub fn deinit(self: *Response, allocator: std.mem.Allocator) void { + self.document.deinit(); + allocator.free(self.buffer); + } + }; - if (document.root.findChildByTag("information")) |information| { - var categories_it = information.findChildrenByTag("category"); - while (categories_it.next()) |category| { - if (std.mem.eql(u8, category.getAttribute("name").?, "meta")) { - var info_it = category.findChildrenByTag("info"); - while (info_it.next()) |info| { - const info_name = info.getAttribute("name").?; - if (std.mem.eql(u8, info_name, "title")) - title = try processHtmlString(allocator, info.children[0].char_data) - else if (std.mem.eql(u8, info_name, "album")) - album = try processHtmlString(allocator, info.children[0].char_data) - else if (std.mem.eql(u8, info_name, "artist")) - artist = try processHtmlString(allocator, info.children[0].char_data); + pub const SongInfo = struct { + allocator: std.mem.Allocator, + title: ?[]const u8, + album: ?[]const u8, + artist: ?[]const u8, + + // TODO move allocator into struct + pub fn deinit(self: *SongInfo) void { + if (self.title) |b| self.allocator.free(b); + if (self.album) |b| self.allocator.free(b); + if (self.artist) |b| self.allocator.free(b); + } + }; + + pub fn getSongInfo(self: *VLC) !SongInfo { + var response = try self.request(.{ .scheme = "http", .path = .{ .percent_encoded = "/requests/status.xml" } }); + defer response.deinit(self.allocator); + + // std.debug.print("{s}\n", .{response.buffer}); + + var title: ?[]const u8 = null; + var album: ?[]const u8 = null; + var artist: ?[]const u8 = null; + + if (response.document.root.findChildByTag("information")) |information| { + var categories_it = information.findChildrenByTag("category"); + while (categories_it.next()) |category| { + if (std.mem.eql(u8, category.getAttribute("name").?, "meta")) { + var info_it = category.findChildrenByTag("info"); + while (info_it.next()) |info| { + const info_name = info.getAttribute("name").?; + if (std.mem.eql(u8, info_name, "title")) + title = try processHtmlString(self, info.children[0].char_data) + else if (std.mem.eql(u8, info_name, "album")) + album = try processHtmlString(self, info.children[0].char_data) + else if (std.mem.eql(u8, info_name, "artist")) + artist = try processHtmlString(self, info.children[0].char_data); + } } } } + + return .{ + .allocator = self.allocator, + .title = title, + .album = album, + .artist = artist, + }; } - return .{ - .title = title, - .album = album, - .artist = artist, - }; -} - -fn processHtmlString(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { - var new: []u8 = try allocator.dupe(u8, string); - errdefer allocator.free(new); - while (true) { - if (std.mem.indexOf(u8, new, "&#")) |amp| { - if (std.mem.indexOfScalarPos(u8, new, amp, ';')) |semi| { - const int = try std.fmt.parseInt(u8, new[amp + 2 .. semi], 10); - const nnew = try allocator.alloc(u8, std.mem.replacementSize(u8, new, new[amp .. semi + 1], &[1]u8{int})); - _ = std.mem.replace(u8, new, new[amp .. semi + 1], &[1]u8{int}, nnew); - allocator.free(new); - new = nnew; - } - } else break; + // TODO clean up + fn processHtmlString(self: *VLC, string: []const u8) ![]const u8 { + var new: []u8 = try self.allocator.dupe(u8, string); + errdefer self.allocator.free(new); + while (true) { + if (std.mem.indexOf(u8, new, "&#")) |amp| { + if (std.mem.indexOfScalarPos(u8, new, amp, ';')) |semi| { + const int = try std.fmt.parseInt(u8, new[amp + 2 .. semi], 10); + const nnew = try self.allocator.alloc(u8, std.mem.replacementSize(u8, new, new[amp .. semi + 1], &[1]u8{int})); + _ = std.mem.replace(u8, new, new[amp .. semi + 1], &[1]u8{int}, nnew); + self.allocator.free(new); + new = nnew; + } + } else break; + } + // std.debug.print("{s}\n", .{new}); + return new; } - std.debug.print("{s}\n", .{new}); - return new; -} + + const xml = @import("./xml.zig"); +}; fn updateTime(tz: std.Tz) !void { const original_timestamp = std.time.timestamp(); From b419eeb1c43a53d2aaa2ac123adea155fe14d5f1 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Mon, 17 Feb 2025 08:22:40 -0700 Subject: [PATCH 04/10] websocket and browser source, quick and dirty implementation of the statusline --- build.zig.zon | 1 + flake.lock | 6 +- src/main.zig | 121 +++++++++++++++++++++++++++++++++------- src/web/gone.html | 13 +++++ src/web/gone.js | 49 ++++++++++++++++ src/web/statusline.html | 12 ++++ src/web/statusline.js | 12 ++++ src/web/style.css | 16 ++++++ 8 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 src/web/gone.html create mode 100644 src/web/gone.js create mode 100644 src/web/statusline.html create mode 100644 src/web/statusline.js create mode 100644 src/web/style.css diff --git a/build.zig.zon b/build.zig.zon index e2f4d43..124e42f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,5 +7,6 @@ "build.zig", "build.zig.zon", "src", + "public", }, } diff --git a/flake.lock b/flake.lock index 2cdb40e..508152a 100644 --- a/flake.lock +++ b/flake.lock @@ -59,11 +59,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1739496552, - "narHash": "sha256-if34rjhH/CXZQTnAl629tVdz/mAx/fifjTPRPQsJ1tg=", + "lastModified": 1739670160, + "narHash": "sha256-9QI3sDNp+Pmr7mcaeabqXrADaF8SAZ98LnB/I23FnXs=", "owner": "Cloudef", "repo": "zig2nix", - "rev": "0dae566efe9a0ed18c07b76a5ed8ff2c546bdd56", + "rev": "3dae27fb3c702b7f28425518e914b0ab34575ff5", "type": "github" }, "original": { diff --git a/src/main.zig b/src/main.zig index 66b6bc3..1666a50 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,6 +14,12 @@ const std = @import("std"); // // A - means immediately, a + means eventually. +var global_connection: ?std.net.Server.Connection = null; +var global_websocket: ?*std.http.WebSocket = null; + +var global_send_buf: ?[]u8 = null; +var global_recv_buf: ?[]align(4) u8 = null; + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -27,10 +33,82 @@ pub fn main() !void { var tz = try std.tz.Tz.parse(allocator, tz_file.reader()); defer tz.deinit(); - while (true) : (std.time.sleep(1_000_000_000)) { // sleep 500ms - try updateTime(tz); - try updateStatus(allocator, &vlc); - scroll += 1; + var server = try std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 8009).listen(.{}); + defer server.deinit(); + + // while (true) : (std.time.sleep(1_000_000_000)) { // sleep 500ms + // try updateTime(tz); + // try updateStatus(allocator, &vlc); + // // scroll += 1; + // } + + defer if (global_connection) |conn| conn.stream.close(); + defer if (global_send_buf) |buf| allocator.free(buf); + defer if (global_recv_buf) |buf| allocator.free(buf); + + var delay: u8 = 0; + while (true) { + var pollfds = [_]std.posix.pollfd{.{ .fd = server.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }}; + const num = try std.posix.poll(&pollfds, 100); + std.debug.print("num pollfds {d}\nrevents {d}\n", .{ num, pollfds[0].revents }); + if (pollfds[0].revents != 0) { + const read_buf = try allocator.alloc(u8, 1024 * 32); + defer allocator.free(read_buf); + + const connection = try server.accept(); + errdefer connection.stream.close(); + + var http = std.http.Server.init(connection, read_buf); + var request = try http.receiveHead(); + std.debug.print("Target: {s}\n", .{request.head.target}); + + var websocket: std.http.WebSocket = undefined; + + global_send_buf = try allocator.alloc(u8, 1024 * 32); + global_recv_buf = try allocator.alignedAlloc(u8, 4, 1024 * 32); + + const is_websocket = try websocket.init(&request, global_send_buf.?, global_recv_buf.?); + if (is_websocket) { + try websocket.response.flush(); + std.debug.print("is a websocket now\n", .{}); + global_connection = connection; + global_websocket = &websocket; + // const msg = try websocket.readSmallMessage(); + // std.debug.print("{any}\n{s}\n", .{ msg, msg.data }); + + // try websocket.writeMessage("dude i cant believe you just ate that", .text); + // // try websocket.writeMessage("", .ping); + // std.time.sleep(5_000_000_000); + // try websocket.writeMessage("dude why did i just eat that now", .text); + // std.debug.print("sent second one\n", .{}); + } else { + defer connection.stream.close(); + switch (request.head.method) { + .GET, .HEAD => { + if (std.mem.eql(u8, request.head.target, "/gone")) { + try request.respond(@embedFile("web/gone.html"), .{}); + } else if (std.mem.eql(u8, request.head.target, "/statusline")) { + try request.respond(@embedFile("web/statusline.html"), .{}); + } else if (std.mem.eql(u8, request.head.target, "/gone.js")) { + try request.respond(@embedFile("web/gone.js"), .{}); + } else if (std.mem.eql(u8, request.head.target, "/statusline.js")) { + try request.respond(@embedFile("web/statusline.js"), .{}); + } else if (std.mem.eql(u8, request.head.target, "/style.css")) { + try request.respond(@embedFile("web/style.css"), .{}); + } else { + try request.respond("", .{ .status = .not_found }); + } + }, + else => try request.respond("", .{ .status = .not_found }), + } + } + } + + delay += 1; + if (delay == 10) { + delay = 0; + try updateStatus(allocator, &vlc); + } } } @@ -39,27 +117,30 @@ const base64_encoder = std.base64.standard.Encoder; // TODO make the URL something short like jeevio.xyz/streamboy const topic = "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy"; -var scroll: usize = 0; +// var scroll: usize = 0; fn updateStatus(allocator: std.mem.Allocator, vlc: *VLC) !void { - var stream_info_file = try std.fs.createFileAbsolute("/tmp/streaminfo", .{ .truncate = true }); - defer stream_info_file.close(); + // var stream_info_file = try std.fs.createFileAbsolute("/tmp/streaminfo", .{ .truncate = true }); + // defer stream_info_file.close(); + if (global_websocket) |ws| { + var song_info = try vlc.getSongInfo(); + defer song_info.deinit(); - var song_info = try vlc.getSongInfo(); - defer song_info.deinit(); + const string = try std.fmt.allocPrint(allocator, "{s} | ♪ {s} - {s} | ", .{ + topic, + song_info.title orelse "Unknown Title", + song_info.artist orelse "Unknown Artist", + }); + defer allocator.free(string); - const string = try std.fmt.allocPrint(allocator, "{s} | ♪ {s} - {s} | ", .{ - topic, - song_info.title orelse "Unknown Title", - song_info.artist orelse "Unknown Artist", - }); - defer allocator.free(string); - - if (scroll > string.len) scroll = 0; - if (scroll == 0) try stream_info_file.writeAll(string) else { - for (string[scroll..]) |char| try stream_info_file.writer().writeByte(char); - for (string[0..scroll]) |char| try stream_info_file.writer().writeByte(char); + try ws.writeMessage(string, .text); } + + // if (scroll > string.len) scroll = 0; + // if (scroll == 0) try stream_info_file.writeAll(string) else { + // for (string[scroll..]) |char| try stream_info_file.writer().writeByte(char); + // for (string[0..scroll]) |char| try stream_info_file.writer().writeByte(char); + // } } pub const VLC = struct { diff --git a/src/web/gone.html b/src/web/gone.html new file mode 100644 index 0000000..14d5531 --- /dev/null +++ b/src/web/gone.html @@ -0,0 +1,13 @@ + + + + + + + + +

[gone]

+

+ + + diff --git a/src/web/gone.js b/src/web/gone.js new file mode 100644 index 0000000..8d200f3 --- /dev/null +++ b/src/web/gone.js @@ -0,0 +1,49 @@ +// Sunday, February 16 2025, 03:06:20 PM + +const dateElement = document.getElementById("date"); + +const months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const daysOfWeek = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +function updateDateTime() { + const date = new Date(); + + const seconds = date.getSeconds(); + const minutes = date.getMinutes(); + const hours24 = date.getHours(); + const hours12 = hours24 % 12; + const ampm = hours24 > 11 && hours24 != 0 ? "PM" : "AM"; + const secondsString = String(seconds).padStart(2, "0"); + const minutesString = String(minutes).padStart(2, "0"); + + const year = date.getFullYear(); + const month = months[date.getMonth()]; + const day = date.getDate(); + const dayOfWeek = daysOfWeek[date.getDay()]; + + dateElement.innerText = `${dayOfWeek}, ${month} ${day} ${year}, ${hours12}:${minutesString}:${secondsString} ${ampm}`; +} + +setInterval(updateDateTime, 1000); diff --git a/src/web/statusline.html b/src/web/statusline.html new file mode 100644 index 0000000..9a461ca --- /dev/null +++ b/src/web/statusline.html @@ -0,0 +1,12 @@ + + + + + + + + +

+ + + diff --git a/src/web/statusline.js b/src/web/statusline.js new file mode 100644 index 0000000..f3670fe --- /dev/null +++ b/src/web/statusline.js @@ -0,0 +1,12 @@ +const statusLineElement = document.getElementById("status-line"); + +const socket = new WebSocket("ws://localhost:8009"); + +// socket.addEventListener("open", (event) => { +// socket.send("Hello Server!"); +// }); + +socket.addEventListener("message", (event) => { + console.log("Message from server ", event.data); + statusLineElement.textContent = event.data; +}); diff --git a/src/web/style.css b/src/web/style.css new file mode 100644 index 0000000..9e78832 --- /dev/null +++ b/src/web/style.css @@ -0,0 +1,16 @@ +body { + font-family: Unifont; + background-color: black; + color: white; + margin: 0px auto; + overflow: hidden; +} + +#gone { + font-size: 64px; +} + +#status-line { + font-size: 16px; + margin: 0px; +} From 9b84d6f651eda8af1247a107cdfc5ada4b60d33a Mon Sep 17 00:00:00 2001 From: Jeeves Date: Tue, 18 Feb 2025 16:06:08 -0700 Subject: [PATCH 05/10] shelving this version for a bit --- src/main.zig | 139 +++++++++++++++++++++++------------------- src/web/statusline.js | 17 ++++-- src/web/style.css | 12 ++++ 3 files changed, 100 insertions(+), 68 deletions(-) diff --git a/src/main.zig b/src/main.zig index 1666a50..5e60285 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,12 +14,6 @@ const std = @import("std"); // // A - means immediately, a + means eventually. -var global_connection: ?std.net.Server.Connection = null; -var global_websocket: ?*std.http.WebSocket = null; - -var global_send_buf: ?[]u8 = null; -var global_recv_buf: ?[]align(4) u8 = null; - pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -28,30 +22,33 @@ pub fn main() !void { var vlc = try VLC.init(allocator, try std.Uri.parse("http://localhost:8080")); defer vlc.deinit(); - var tz_file = try std.fs.openFileAbsolute("/etc/localtime", .{}); - defer tz_file.close(); - var tz = try std.tz.Tz.parse(allocator, tz_file.reader()); - defer tz.deinit(); + // var tz_file = try std.fs.openFileAbsolute("/etc/localtime", .{}); + // defer tz_file.close(); + // var tz = try std.tz.Tz.parse(allocator, tz_file.reader()); + // defer tz.deinit(); var server = try std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 8009).listen(.{}); defer server.deinit(); - // while (true) : (std.time.sleep(1_000_000_000)) { // sleep 500ms - // try updateTime(tz); - // try updateStatus(allocator, &vlc); - // // scroll += 1; - // } + var clients = std.ArrayList(Client).init(allocator); + defer { + for (0..clients.items.len) |i| clients.items[i].deinit(allocator); + clients.deinit(); + } - defer if (global_connection) |conn| conn.stream.close(); - defer if (global_send_buf) |buf| allocator.free(buf); - defer if (global_recv_buf) |buf| allocator.free(buf); - - var delay: u8 = 0; + // var delay: u8 = 0; while (true) { - var pollfds = [_]std.posix.pollfd{.{ .fd = server.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }}; - const num = try std.posix.poll(&pollfds, 100); - std.debug.print("num pollfds {d}\nrevents {d}\n", .{ num, pollfds[0].revents }); - if (pollfds[0].revents != 0) { + var pollfds = std.ArrayList(std.posix.pollfd).init(allocator); + defer pollfds.deinit(); + try pollfds.append(.{ .fd = server.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }); + for (clients.items, 0..) |client, i| { + std.debug.print("appending client {d} to poll()\n", .{i}); + try pollfds.append(.{ .fd = client.connection.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }); + } + const num = try std.posix.poll(pollfds.items, 100); + // std.debug.print("num pollfds {d}\n", .{num}); + _ = num; + if (pollfds.items[0].revents != 0) { const read_buf = try allocator.alloc(u8, 1024 * 32); defer allocator.free(read_buf); @@ -64,24 +61,26 @@ pub fn main() !void { var websocket: std.http.WebSocket = undefined; - global_send_buf = try allocator.alloc(u8, 1024 * 32); - global_recv_buf = try allocator.alignedAlloc(u8, 4, 1024 * 32); + const send_buf = try allocator.alloc(u8, 1024 * 32); + errdefer allocator.free(send_buf); + const recv_buf = try allocator.alignedAlloc(u8, 4, 1024 * 32); + errdefer allocator.free(recv_buf); - const is_websocket = try websocket.init(&request, global_send_buf.?, global_recv_buf.?); + const is_websocket = try websocket.init(&request, send_buf, recv_buf); if (is_websocket) { + // if websocket, we clean up at the end of main(), not the end of the block try websocket.response.flush(); std.debug.print("is a websocket now\n", .{}); - global_connection = connection; - global_websocket = &websocket; - // const msg = try websocket.readSmallMessage(); - // std.debug.print("{any}\n{s}\n", .{ msg, msg.data }); - // try websocket.writeMessage("dude i cant believe you just ate that", .text); - // // try websocket.writeMessage("", .ping); - // std.time.sleep(5_000_000_000); - // try websocket.writeMessage("dude why did i just eat that now", .text); - // std.debug.print("sent second one\n", .{}); + const client = Client{ + .connection = connection, + .websocket = websocket, + .send_buf = send_buf, + .recv_buf = recv_buf, + }; + try clients.append(client); } else { + // if normal HTTP, clean up at the end of the block defer connection.stream.close(); switch (request.head.method) { .GET, .HEAD => { @@ -104,43 +103,57 @@ pub fn main() !void { } } - delay += 1; - if (delay == 10) { - delay = 0; - try updateStatus(allocator, &vlc); + const status_string = try getStatusString(allocator, &vlc); + defer allocator.free(status_string); + for (1..pollfds.items.len) |i| { + const pollfd = pollfds.items[i]; + var client = &clients.items[i - 1]; + std.debug.print("client idx {d} for status update\nrevents {d}\n", .{ i - 1, pollfd.revents }); + // try std.http.WebSocket.writeMessage(&client.websocket, status_string, .text); + if (pollfd.revents & std.posix.POLL.IN != 0) { + const data = std.http.WebSocket.readSmallMessage(&client.websocket) catch |e| switch (e) { + error.EndOfStream => continue, + error.ConnectionClose => continue, + else => return e, + }; + std.debug.print("recieved opcode: {any} with data {s}\n", .{ data.opcode, data.data }); + } } + + std.Thread.sleep(1_000_000_000); } } +const Client = struct { + connection: std.net.Server.Connection, + websocket: std.http.WebSocket, + send_buf: []u8, + recv_buf: []align(4) u8, + + pub fn deinit(self: *Client, allocator: std.mem.Allocator) void { + allocator.free(self.send_buf); + allocator.free(self.recv_buf); + self.connection.stream.close(); + } +}; + const base64_encoder = std.base64.standard.Encoder; // TODO make the URL something short like jeevio.xyz/streamboy const topic = "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy"; -// var scroll: usize = 0; +fn getStatusString(allocator: std.mem.Allocator, vlc: *VLC) ![]u8 { + var song_info = try vlc.getSongInfo(); + defer song_info.deinit(); -fn updateStatus(allocator: std.mem.Allocator, vlc: *VLC) !void { - // var stream_info_file = try std.fs.createFileAbsolute("/tmp/streaminfo", .{ .truncate = true }); - // defer stream_info_file.close(); - if (global_websocket) |ws| { - var song_info = try vlc.getSongInfo(); - defer song_info.deinit(); + const string = try std.fmt.allocPrint(allocator, "{s} | ♪ {s} - {s} | ", .{ + topic, + song_info.title orelse "Unknown Title", + song_info.artist orelse "Unknown Artist", + }); + errdefer allocator.free(string); - const string = try std.fmt.allocPrint(allocator, "{s} | ♪ {s} - {s} | ", .{ - topic, - song_info.title orelse "Unknown Title", - song_info.artist orelse "Unknown Artist", - }); - defer allocator.free(string); - - try ws.writeMessage(string, .text); - } - - // if (scroll > string.len) scroll = 0; - // if (scroll == 0) try stream_info_file.writeAll(string) else { - // for (string[scroll..]) |char| try stream_info_file.writer().writeByte(char); - // for (string[0..scroll]) |char| try stream_info_file.writer().writeByte(char); - // } + return string; } pub const VLC = struct { @@ -159,6 +172,7 @@ pub const VLC = struct { const authorization = try std.fmt.allocPrint(allocator, "Basic {s}", .{base64_encoder.encode(base64_userpass, userpass)}); errdefer allocator.free(authorization); + // TODO actually launch VLC // var vlc = std.process.Child.init(&[_][]const u8{ // "vlc", // "--intf", @@ -226,7 +240,6 @@ pub const VLC = struct { album: ?[]const u8, artist: ?[]const u8, - // TODO move allocator into struct pub fn deinit(self: *SongInfo) void { if (self.title) |b| self.allocator.free(b); if (self.album) |b| self.allocator.free(b); diff --git a/src/web/statusline.js b/src/web/statusline.js index f3670fe..ed564cf 100644 --- a/src/web/statusline.js +++ b/src/web/statusline.js @@ -2,11 +2,18 @@ const statusLineElement = document.getElementById("status-line"); const socket = new WebSocket("ws://localhost:8009"); -// socket.addEventListener("open", (event) => { -// socket.send("Hello Server!"); -// }); - socket.addEventListener("message", (event) => { - console.log("Message from server ", event.data); + console.log(event); statusLineElement.textContent = event.data; }); + +// const status = { +// blocks: [ +// "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy", +// "♪ Bonhomme de Neige (EarthBound) - Ridley Snipes feat. Earth Kid", +// ], +// }; + +// setInterval(() => { +// // statusText = "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy | ♪ Bonhomme de Neige (EarthBound) - Ridley Snipes feat. Earth Kid | "; +// }, 5000); diff --git a/src/web/style.css b/src/web/style.css index 9e78832..73d62d2 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -13,4 +13,16 @@ body { #status-line { font-size: 16px; margin: 0px; + white-space: nowrap; + animation: marquee 5s linear infinite; + max-width: none; +} + +@keyframes marquee { + from { + left: -100%; + } + to { + left: 100%; + } } From a0ae1b422d8b704fb4119c2c8486adcc49ea6bf2 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Tue, 18 Feb 2025 22:03:27 -0700 Subject: [PATCH 06/10] init --- deno.json | 9 ++++ deno.lock | 97 ++++++++++++++++++++++++++++++++++++++++ flake.lock | 61 +++++++++++++++++++++++++ flake.nix | 15 +++++++ shell.nix | 5 +++ src/main.ts | 106 ++++++++++++++++++++++++++++++++++++++++++++ web/statusline.html | 12 +++++ web/statusline.js | 46 +++++++++++++++++++ web/style.css | 20 +++++++++ 9 files changed, 371 insertions(+) create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 shell.nix create mode 100644 src/main.ts create mode 100644 web/statusline.html create mode 100644 web/statusline.js create mode 100644 web/style.css diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..565768e --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "dev": "deno run --allow-net --allow-read --watch src/main.ts" + }, + "imports": { + "@oak/oak": "jsr:@oak/oak@^17.1.4", + "@std/assert": "jsr:@std/assert@1" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..3fa19fc --- /dev/null +++ b/deno.lock @@ -0,0 +1,97 @@ +{ + "version": "4", + "specifiers": { + "jsr:@oak/commons@1": "1.0.0", + "jsr:@oak/oak@^17.1.4": "17.1.4", + "jsr:@std/assert@1": "1.0.11", + "jsr:@std/bytes@1": "1.0.5", + "jsr:@std/crypto@1": "1.0.4", + "jsr:@std/encoding@1": "1.0.7", + "jsr:@std/encoding@^1.0.7": "1.0.7", + "jsr:@std/fmt@0.223": "0.223.0", + "jsr:@std/http@1": "1.0.13", + "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/media-types@1": "1.1.0", + "jsr:@std/path@1": "1.0.8", + "npm:fast-xml-parser@*": "4.5.2", + "npm:path-to-regexp@^6.3.0": "6.3.0" + }, + "jsr": { + "@oak/commons@1.0.0": { + "integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/http", + "jsr:@std/media-types" + ] + }, + "@oak/oak@17.1.4": { + "integrity": "60530b582bf276ff741e39cc664026781aa08dd5f2bc5134d756cc427bf2c13e", + "dependencies": [ + "jsr:@oak/commons", + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/http", + "jsr:@std/media-types", + "jsr:@std/path", + "npm:path-to-regexp" + ] + }, + "@std/assert@1.0.11": { + "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/bytes@1.0.5": { + "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" + }, + "@std/crypto@1.0.4": { + "integrity": "cee245c453bd5366207f4d8aa25ea3e9c86cecad2be3fefcaa6cb17203d79340" + }, + "@std/encoding@1.0.7": { + "integrity": "f631247c1698fef289f2de9e2a33d571e46133b38d042905e3eac3715030a82d" + }, + "@std/fmt@0.223.0": { + "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" + }, + "@std/http@1.0.13": { + "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e", + "dependencies": [ + "jsr:@std/encoding@^1.0.7" + ] + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + } + }, + "npm": { + "fast-xml-parser@4.5.2": { + "integrity": "sha512-xmnYV9o0StIz/0ArdzmWTxn9oDy0lH8Z80/8X/TD2EUQKXY4DHxoT9mYBqgGIG17DgddCJtH1M6DriMbalNsAA==", + "dependencies": [ + "strnum" + ] + }, + "path-to-regexp@6.3.0": { + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + "strnum@1.0.5": { + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@oak/oak@^17.1.4", + "jsr:@std/assert@1" + ] + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9cc4e2e --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1739736696, + "narHash": "sha256-zON2GNBkzsIyALlOCFiEBcIjI4w38GYOb+P+R4S8Jsw=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d74a2335ac9c133d6bbec9fc98d91a77f1604c1f", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..82932f8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,15 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem(system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + # packages.default = pkgs.callPackage ./default.nix {}; + devShells.default = import ./shell.nix { inherit pkgs; }; + }); +} + diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..217f314 --- /dev/null +++ b/shell.nix @@ -0,0 +1,5 @@ +{ pkgs ? import {} }: +pkgs.mkShell { + packages = with pkgs; [deno]; +} + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..87223c8 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,106 @@ +import { Application, Router } from "@oak/oak"; +import { XMLParser } from "npm:fast-xml-parser"; + +const router = new Router(); + +let wsClients: WebSocket[] = []; + +router.get("/", (context) => { + if (context.isUpgradable) { + const ws = context.upgrade(); + ws.onopen = () => wsClients.push(ws); + ws.onclose = () => wsClients = wsClients.filter((client) => client != ws); + } +}); + +router + .get("/statusline", async (ctx) => { + ctx.response.body = await Deno.readFile("web/statusline.html"); + }) + .get("/statusline.js", async (ctx) => { + ctx.response.body = await Deno.readFile("web/statusline.js"); + }) + .get("/style.css", async (ctx) => { + ctx.response.body = await Deno.readFile("web/style.css"); + }); + +const app = new Application(); +app.use(router.routes()); +app.use(router.allowedMethods()); + +app.addEventListener("listen", ({ hostname, port }) => { + console.log(`Start listening on ${hostname}:${port}`); +}); + +app.listen({ port: 8012 }); + +const topic = + "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy"; + +setInterval(async () => { + const songinfo = await getVlcSongInfo(); + const data = { + blocks: [ + { text: topic, color: "#ffffff" }, + { text: `♪ ${songinfo.title} - ${songinfo.artist}`, color: "#ffffff" }, + ], + }; + for (const ws of wsClients) { + // ws.send( + // `${topic} | ♪ ${songinfo.title} - ${songinfo.artist} | `, + // ); + // + ws.send(JSON.stringify(data)); + } +}, 900); + +interface SongInfo { + title?: string; + artist?: string; + album?: string; +} + +async function getVlcSongInfo(): Promise { + const parser = new XMLParser({ ignoreAttributes: false }); + const password = "1234"; + + const res = await fetch("http://localhost:8080/requests/status.xml", { + headers: { authorization: `Basic ${btoa(`:${password}`)}` }, + }); + + const json = parser.parse(await res.text()); + + const songinfo: SongInfo = {}; + + for (const category of json.root.information.category) { + if (category["@_name"] != "meta") continue; + for (const property of category.info) { + if (property["@_name"] == "title") { + songinfo.title = processBadXmlString(property["#text"]); + } else if (property["@_name"] == "artist") { + songinfo.artist = processBadXmlString(property["#text"]); + } else if (property["@_name"] == "album") { + songinfo.album = processBadXmlString(property["#text"]); + } + } + } + + return songinfo; +} + +function processBadXmlString(str: string): string { + let newStr = str; + + while (true) { + const amp = newStr.indexOf("&#"); + if (amp > 0) { + const semi = newStr.indexOf(";", amp); + if (semi > 0) { + const int = String.fromCharCode(parseInt(newStr.slice(amp + 2, semi))); + newStr = newStr.replace(newStr.slice(amp, semi + 1), int); + } else break; + } else break; + } + + return newStr; +} diff --git a/web/statusline.html b/web/statusline.html new file mode 100644 index 0000000..ec822d8 --- /dev/null +++ b/web/statusline.html @@ -0,0 +1,12 @@ + + + + + + + +

Streamboy is connecting...

+ + + + diff --git a/web/statusline.js b/web/statusline.js new file mode 100644 index 0000000..a6797dd --- /dev/null +++ b/web/statusline.js @@ -0,0 +1,46 @@ +const statusLineElement = document.getElementById("status-line"); +const statusLineElement2 = statusLineElement.cloneNode(); +statusLineElement2.id = "status-line2"; +document.body.appendChild(statusLineElement2); + +let status = "Streamboy is connecting..."; +let scroll = 0; + +setInterval(() => { + if (scroll >= status.length) scroll = 0; + // if (scroll == 0) { + // statusLineElement.textContent = status; + // } else { + // let string = ""; + // string += status.slice(scroll); + // string += status.slice(0, scroll); + // statusLineElement.textContent = string; + // } + const stringWidth = 8 * status.length; + statusLineElement.style.left = `${-8 * scroll}px`; + statusLineElement2.style.left = `${-8 * scroll + stringWidth}px`; + // console.log(statusLineElement.style.left) + statusLineElement.textContent = status; + statusLineElement2.textContent = status; + scroll += 1; +}, 750); + +const socket = new WebSocket("ws://localhost:8012"); + +socket.addEventListener("message", (event) => { + const data = JSON.parse(event.data); + + let string = ""; + for (const block of data.blocks) { + string += block.text; + string += " | "; + } + status = string; +}); + +// const status = { +// blocks: [ +// "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy", +// "♪ Bonhomme de Neige (EarthBound) - Ridley Snipes feat. Earth Kid", +// ], +// }; diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..2e66959 --- /dev/null +++ b/web/style.css @@ -0,0 +1,20 @@ +body { + font-family: Unifont; + background-color: black; + color: white; + margin: 0px auto; + overflow: hidden; +} + +#gone { + font-size: 64px; +} + +#status-line, #status-line2 { + font-size: 16px; + margin: 0px; + white-space: preserve nowrap; + max-width: none; + text-align: center; + position: absolute; +} From 364578bd7ec584d126e1958152f72580951f0b21 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Tue, 18 Feb 2025 22:13:46 -0700 Subject: [PATCH 07/10] add README --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffe96ec --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Streamboy! + +Jeeves' personal streaming toolkit. + +{insert stereotypical message of This Is A Mess Not Designed For Others} so Good Luck Have Fun :) + +# Usage + +0. (Nix only) Run `nix develop` +1. Run VLC with `vlc --extraintf http --http-host localhost --http-password 1234` +2. Run OBS Studio +3. Create a browser source with a height of 19, width that is a multiple of 8, and point it to `http://localhost:8012/statusline` +4. Run Streamboy with `deno run dev` + +Topic is hardcoded for now, but the song info will automatically update with VLC. From baf29cefa50bc3bb23dff12da690ea0f568335c5 Mon Sep 17 00:00:00 2001 From: Jeeves Date: Tue, 18 Feb 2025 22:37:14 -0700 Subject: [PATCH 08/10] better crash resistance with VLC client --- src/main.ts | 49 +++++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/main.ts b/src/main.ts index 87223c8..2ed17a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,18 +38,25 @@ const topic = "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy"; setInterval(async () => { - const songinfo = await getVlcSongInfo(); + let songinfo: SongInfo | null = null; + try { + songinfo = await getVlcSongInfo(); + } catch { + // properly handling this error is by ignoring it, + // since then that leaves songinfo as null, + // and that is guaranteed to be handled correctly. + } + const data = { - blocks: [ - { text: topic, color: "#ffffff" }, - { text: `♪ ${songinfo.title} - ${songinfo.artist}`, color: "#ffffff" }, - ], + blocks: [{ text: topic, color: "#ffffff" }], }; + if (songinfo != null) { + data.blocks.push({ + text: `♪ ${songinfo.title} - ${songinfo.artist}`, + color: "#ffffff", + }); + } for (const ws of wsClients) { - // ws.send( - // `${topic} | ♪ ${songinfo.title} - ${songinfo.artist} | `, - // ); - // ws.send(JSON.stringify(data)); } }, 900); @@ -72,17 +79,23 @@ async function getVlcSongInfo(): Promise { const songinfo: SongInfo = {}; - for (const category of json.root.information.category) { - if (category["@_name"] != "meta") continue; - for (const property of category.info) { - if (property["@_name"] == "title") { - songinfo.title = processBadXmlString(property["#text"]); - } else if (property["@_name"] == "artist") { - songinfo.artist = processBadXmlString(property["#text"]); - } else if (property["@_name"] == "album") { - songinfo.album = processBadXmlString(property["#text"]); + try { + for (const category of json.root.information.category) { + if (category["@_name"] != "meta") continue; + for (const property of category.info) { + if (property["@_name"] == "title") { + songinfo.title = processBadXmlString(property["#text"]); + } else if (property["@_name"] == "artist") { + songinfo.artist = processBadXmlString(property["#text"]); + } else if (property["@_name"] == "album") { + songinfo.album = processBadXmlString(property["#text"]); + } } } + } catch { + songinfo.title = "Unknown Title"; + songinfo.artist = "Unknown Artist"; + songinfo.album = "Unknown Album"; } return songinfo; From 5a70cafef24d5434744ad031cad34cd1ab9cc22c Mon Sep 17 00:00:00 2001 From: Jeeves Date: Tue, 18 Feb 2025 23:24:01 -0700 Subject: [PATCH 09/10] better scrolling --- web/statusline.js | 61 ++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/web/statusline.js b/web/statusline.js index a6797dd..e41dac2 100644 --- a/web/statusline.js +++ b/web/statusline.js @@ -3,44 +3,39 @@ const statusLineElement2 = statusLineElement.cloneNode(); statusLineElement2.id = "status-line2"; document.body.appendChild(statusLineElement2); -let status = "Streamboy is connecting..."; +let status = { blocks: [{ text: "Streamboy is connecting...", color: "#ffffff" }] }; let scroll = 0; setInterval(() => { - if (scroll >= status.length) scroll = 0; - // if (scroll == 0) { - // statusLineElement.textContent = status; - // } else { - // let string = ""; - // string += status.slice(scroll); - // string += status.slice(0, scroll); - // statusLineElement.textContent = string; - // } - const stringWidth = 8 * status.length; - statusLineElement.style.left = `${-8 * scroll}px`; - statusLineElement2.style.left = `${-8 * scroll + stringWidth}px`; - // console.log(statusLineElement.style.left) - statusLineElement.textContent = status; - statusLineElement2.textContent = status; - scroll += 1; -}, 750); + let string = ""; + for (const i in status.blocks) { + string += status.blocks[i].text; + if (i < status.blocks.length - 1) string += " | "; + } + + if (scroll >= string.length + 9) scroll = 0; + const stringWidth = 8 * string.length + 72; + if (8 * string.length <= statusLineElement.parentElement.clientWidth) { + scroll = 0; + statusLineElement.style.removeProperty("position"); + statusLineElement.style.removeProperty("left"); + statusLineElement2.style.display = "none"; + statusLineElement.textContent = string; + statusLineElement2.textContent = string; + } else { + statusLineElement.style.position = "absolute"; + statusLineElement.style.left = `${-8 * scroll}px`; + statusLineElement2.style.position = "absolute"; + statusLineElement2.style.left = `${-8 * scroll + stringWidth}px`; + statusLineElement2.style.removeProperty("display"); + scroll += 1; + statusLineElement.textContent = string + " | "; + statusLineElement2.textContent = string + " | "; + } +}, 500); const socket = new WebSocket("ws://localhost:8012"); socket.addEventListener("message", (event) => { - const data = JSON.parse(event.data); - - let string = ""; - for (const block of data.blocks) { - string += block.text; - string += " | "; - } - status = string; + status = JSON.parse(event.data); }); - -// const status = { -// blocks: [ -// "Something Fun For Everyone With Streamboy!!!!!!!! git.jeevio.xyz/jeeves/streamboy", -// "♪ Bonhomme de Neige (EarthBound) - Ridley Snipes feat. Earth Kid", -// ], -// }; From 5538d4bc8ad6d016e66885b5f7cb0c746c34485a Mon Sep 17 00:00:00 2001 From: Jeeves Date: Wed, 19 Feb 2025 10:54:32 -0700 Subject: [PATCH 10/10] gone screen --- src/main.ts | 9 ++++- web/gone.html | 14 ++++++++ web/gone.js | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++ web/style.css | 23 ++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 web/gone.html create mode 100644 web/gone.js diff --git a/src/main.ts b/src/main.ts index 2ed17a8..83fe83e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,12 @@ router .get("/statusline.js", async (ctx) => { ctx.response.body = await Deno.readFile("web/statusline.js"); }) + .get("/gone", async (ctx) => { + ctx.response.body = await Deno.readFile("web/gone.html"); + }) + .get("/gone.js", async (ctx) => { + ctx.response.body = await Deno.readFile("web/gone.js"); + }) .get("/style.css", async (ctx) => { ctx.response.body = await Deno.readFile("web/style.css"); }); @@ -41,7 +47,8 @@ setInterval(async () => { let songinfo: SongInfo | null = null; try { songinfo = await getVlcSongInfo(); - } catch { + } catch (e) { + console.log(`getVlcSongInfo() error: ${e}`); // properly handling this error is by ignoring it, // since then that leaves songinfo as null, // and that is guaranteed to be handled correctly. diff --git a/web/gone.html b/web/gone.html new file mode 100644 index 0000000..0e9ea0f --- /dev/null +++ b/web/gone.html @@ -0,0 +1,14 @@ + + + + + + + +

Streamboy is connecting...

+

[gone]

+

+ + + + diff --git a/web/gone.js b/web/gone.js new file mode 100644 index 0000000..ea33bcf --- /dev/null +++ b/web/gone.js @@ -0,0 +1,96 @@ +// Sunday, February 16 2025, 03:06:20 PM + +const dateElement = document.getElementById("date"); + +const months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const daysOfWeek = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +function updateDateTime() { + const date = new Date(); + + const seconds = date.getSeconds(); + const minutes = date.getMinutes(); + const hours24 = date.getHours(); + const hours12 = hours24 % 12; + const ampm = hours24 > 11 && hours24 != 0 ? "PM" : "AM"; + const secondsString = String(seconds).padStart(2, "0"); + const minutesString = String(minutes).padStart(2, "0"); + + const year = date.getFullYear(); + const month = months[date.getMonth()]; + const day = date.getDate(); + const dayOfWeek = daysOfWeek[date.getDay()]; + + dateElement.innerText = `${dayOfWeek}, ${month} ${day} ${year}, ${hours12}:${minutesString}:${secondsString} ${ampm}`; +} + +updateDateTime(); +setInterval(updateDateTime, 1000); + + + +// modified stuff from statusline.js +// TODO combine both functionalities into the same statusline.js +const statusLineElement = document.getElementById("status-line"); +const statusLineElement2 = statusLineElement.cloneNode(); +statusLineElement2.id = "status-line2"; +document.body.appendChild(statusLineElement2); + +let status = { blocks: [{ text: "Streamboy is connecting...", color: "#ffffff" }] }; +let scroll = 0; + +setInterval(() => { + let string = ""; + for (const i in status.blocks) { + string += status.blocks[i].text; + if (i < status.blocks.length - 1) string += " | "; + } + + if (scroll >= string.length + 9) scroll = 0; + const stringWidth = 16 * string.length + 144; + if (16 * string.length <= statusLineElement.parentElement.clientWidth) { + scroll = 0; + statusLineElement.style.removeProperty("position"); + statusLineElement.style.removeProperty("left"); + statusLineElement2.style.display = "none"; + statusLineElement.textContent = string; + statusLineElement2.textContent = string; + } else { + statusLineElement.style.position = "absolute"; + statusLineElement.style.left = `${-16 * scroll}px`; + statusLineElement2.style.position = "absolute"; + statusLineElement2.style.left = `${-16 * scroll + stringWidth}px`; + statusLineElement2.style.removeProperty("display"); + scroll += 1; + statusLineElement.textContent = string + " | "; + statusLineElement2.textContent = string + " | "; + } +}, 500); + +const socket = new WebSocket("ws://localhost:8012"); + +socket.addEventListener("message", (event) => { + status = JSON.parse(event.data); +}); diff --git a/web/style.css b/web/style.css index 2e66959..e3d4880 100644 --- a/web/style.css +++ b/web/style.css @@ -7,7 +7,25 @@ body { } #gone { + font-size: 224px; + position: absolute; + bottom: 164px; + left: 44px; + margin: 0; + background: linear-gradient(#179d23, #0f552c); + color: transparent; + background-clip: text; +} + +#date { font-size: 64px; + position: absolute; + left: 96px; + bottom: 64px; + margin: 0px; + background: linear-gradient(#26677d, #264f9a); + color: transparent; + background-clip: text; } #status-line, #status-line2 { @@ -18,3 +36,8 @@ body { text-align: center; position: absolute; } + +.doublesize { + font-size: 32px !important; + margin-top: 64px !important; +}