diff --git a/src/app.zig b/src/app.zig new file mode 100644 index 0000000..6ee29cf --- /dev/null +++ b/src/app.zig @@ -0,0 +1,461 @@ +const std = @import("std"); +const mem = std.mem; +const json = std.json; + +const sqlite = @import("sqlite.zig"); +const twitch = @import("twitch.zig"); +const Client = @import("client.zig").Client; +const webhook = @import("webhook.zig"); + +const Config = struct { + token: []const u8, + client_id: []const u8, + refresh_rate: u64, + user_logins: []const User, + webhook_url: []const u8, + + pub fn fromFile(allocator: std.mem.Allocator, path: []const u8) anyerror!@This() { + var file = try std.fs.cwd().openFile(path, .{ + .read = true, + .write = false, + }); + + var stat = try file.stat(); + const file_buffer = try allocator.alloc(u8, stat.size); + _ = try file.readAll(file_buffer); + file.close(); + + var stream = json.TokenStream.init(file_buffer); + + const res = json.parse(@This(), &stream, .{ .allocator = allocator }); + allocator.free(file_buffer); + + return res; + } + + pub fn deinit(self: *@This(), allocator: mem.Allocator) void { + allocator.free(self.token); + allocator.free(self.client_id); + + for (self.user_logins) |user| { + allocator.free(user.user_login); + } + + allocator.free(self.user_logins); + + allocator.free(self.webhook_url); + + self.* = undefined; + } +}; + +const User = struct { user_login: []u8 }; + +const DATABASE_VERSION_CODE = 2; + +const CREATE_TABLES = + \\ CREATE TABLE VERSION + \\ ( + \\ versionCode INTEGER + \\ ); + \\ + \\ INSERT INTO VERSION(versionCode) + \\ VALUES (2); + \\ + \\ CREATE TABLE STREAMER + \\ ( + \\ idStreamer TEXT PRIMARY KEY NOT NULL, + \\ loginStreamer TEXT NOT NULL, + \\ nameStreamer TEXT NOT NULL, + \\ imageUrlStreamer TEXT + \\ ); + \\ + \\ CREATE TABLE STREAM + \\ ( + \\ idStream TEXT PRIMARY KEY NOT NULL, + \\ idStreamer TEXT NOT NULL, + \\ isMatureStream BOOLEAN NOT NULL DEFAULT 'F', + \\ CONSTRAINT FK_STREAM_STREAMER_ID FOREIGN KEY (idStreamer) REFERENCES STREAMER (idStreamer) + \\ ); + \\ + \\ CREATE TABLE VIEWER_COUNT_STREAM + \\ ( + \\ viewerCount INTEGER NOT NULL, + \\ dateViewerCount DATE NOT NULL, + \\ idStream TEXT NOT NULL, + \\ PRIMARY KEY (dateViewerCount, idStream), + \\ CONSTRAINT FK_VIEWER_COUNT_STREAM_ID FOREIGN KEY (idStream) REFERENCES STREAM (idStream) + \\ ); + \\ + \\ CREATE TABLE NAME_STREAM + \\ ( + \\ nameStream TEXT NOT NULL, + \\ dateNameStream DATE NOT NULL, + \\ idStream TEXT NOT NULL, + \\ PRIMARY KEY (dateNameStream, idStream), + \\ CONSTRAINT FK_NAME_STREAM_STREAM_ID FOREIGN KEY (idStream) REFERENCES STREAM (idStream) + \\ ); + \\ + \\ CREATE TABLE GAME + \\ ( + \\ gameId TEXT NOT NULL PRIMARY KEY, + \\ gameName TEXT + \\ ); + \\ + \\ CREATE TABLE IS_STREAMING_GAME + \\ ( + \\ gameId TEXT NOT NULL, + \\ streamId TEXT NOT NULL, + \\ dateGameStream DATE NOT NULL, + \\ PRIMARY KEY (gameId, streamId, dateGameStream), + \\ CONSTRAINT FK_GAME_STREAM_GAME_ID FOREIGN KEY (gameId) REFERENCES GAME (gameId), + \\ CONSTRAINT FK_GAME_STREAM_STREAM_ID FOREIGN KEY (streamId) REFERENCES STREAM (idStream) + \\ ); +; + +const DROP_TABLES = + \\ DROP TABLE IF EXISTS VERSION; + \\ DROP TABLE IF EXISTS NAME_STREAM; + \\ DROP TABLE IF EXISTS VIEWER_COUNT_STREAM; + \\ DROP TABLE IF EXISTS IS_STREAMING_GAME; + \\ DROP TABLE IF EXISTS STREAM; + \\ DROP TABLE IF EXISTS STREAMER; + \\ DROP TABLE IF EXISTS GAME; +; + +allocator: mem.Allocator, +db: sqlite.Database, +headers: std.StringHashMap([]const u8), +wait_event: std.Thread.StaticResetEvent = std.Thread.StaticResetEvent{}, +config: Config, + +pub fn init(allocator: mem.Allocator) anyerror!@This() { + var db = try sqlite.Database.open("data.db"); + + try createTables(&db); + + var config = try Config.fromFile(allocator, "config.json"); + + try Client.globalInit(); + + var client = try Client.init(allocator); + + var headers = std.StringHashMap([]const u8).init(allocator); + try headers.put("Authorization", config.token); + try headers.put("Client-Id", config.client_id); + + try insertOrReplaceStreamers(allocator, &db, &client, &config, &headers); + + client.deinit(); + + return @This(){ + .allocator = allocator, + .db = db, + .headers = headers, + .config = config, + }; +} + +pub fn deinit(self: *@This()) anyerror!void { + std.log.debug("deinit app", .{}); + self.config.deinit(self.allocator); + + self.headers.deinit(); + try self.db.close(); + self.* = undefined; +} + +pub fn run(self: *@This()) anyerror!void { + var loop = true; + while (loop) { + var alertAllocator = std.heap.ArenaAllocator.init(self.allocator); + var allocator = alertAllocator.allocator(); + + var client = try Client.init(allocator); + try updateAlert(allocator, &client, &self.config, &self.db, &self.headers); + alertAllocator.deinit(); + client.deinit(); // TODO Maybe don't recreate client every loop, just change allocator + + std.log.debug("sleeping for {} ns", .{self.config.refresh_rate * std.time.ns_per_ms}); + const res = self.wait_event.timedWait(self.config.refresh_rate * std.time.ns_per_ms); + loop = res == .timed_out; + } +} + +pub fn stop(self: *@This()) void { + self.wait_event.set(); +} + +fn createTables(db: *sqlite.Database) anyerror!void { + var stm = db.prepare("SELECT versionCode FROM VERSION ORDER BY versionCode DESC") catch { + std.log.debug("Creating database", .{}); + + try db.exec(CREATE_TABLES); + return; + }; + + if (stm.next()) { + var code: isize = 0; + + try stm.fetch(.{&code}); + stm.finalize(); + + if (DATABASE_VERSION_CODE == code) { + std.log.debug("Database already created", .{}); + return; + } else { + try db.exec(DROP_TABLES); + std.log.debug("Creating database", .{}); + + try db.exec(CREATE_TABLES); + } + } + stm.finalize(); +} + +fn updateAlert( + allocator: std.mem.Allocator, + client: *Client, + config: *Config, + database: *sqlite.Database, + headers: *std.StringHashMap([]const u8), +) anyerror!void { + var request = std.ArrayList(u8).init(allocator); + + try request.appendSlice("https://api.twitch.tv/helix/streams?"); + + { + var i: u8 = 0; + while (i < config.user_logins.len) : (i += 1) { + if (i != 0) + try request.append('&'); + try request.appendSlice("user_login="); + try request.appendSlice(config.user_logins[i].user_login); + } + } + + try request.append(0); + + var streams: twitch.TwitchRes([]const twitch.Stream) = try client.getJSON( + twitch.TwitchRes([]const twitch.Stream), + @ptrCast([*:0]const u8, request.items), + headers, + ); + + request.deinit(); + + if (streams.data.len > 0) { + var embeds = std.ArrayList(webhook.Embed).init(allocator); + + for (streams.data) |s| { + if (try appendEmbed(allocator, &s, database)) |e| { + std.log.debug("sending {s}", .{s.title}); + try embeds.append(e); + } + } + + if (embeds.items.len > 0) { + var res = try client.postJSON(config.webhook_url, webhook.Webhook{ + .username = "Twitch", + .content = "Live alert", + .embeds = embeds.items, + }, null); + + client.allocator.free(res); + } + embeds.deinit(); + } + + streams.deinit(allocator); +} + +fn appendEmbed(allocator: std.mem.Allocator, stream: *const twitch.Stream, db: *sqlite.Database) anyerror!?webhook.Embed { + if (!try streamExist(db, stream.id)) { + try insertStream(db, stream); + try insertMetadatas(db, stream); + + var fields = std.ArrayList(webhook.Field).init(allocator); // TODO BETTER WAY + var viewer = std.ArrayList(u8).init(allocator); + try std.fmt.format(viewer.writer(), "{}", .{stream.viewer_count}); + + try fields.append(.{ + .name = "Viewer count", + .value = viewer.toOwnedSlice(), + .@"inline" = true, + }); + + try fields.append(.{ + .name = "Game name", + .value = stream.game_name, + .@"inline" = true, + }); + + var thumbnail = try std.mem.replaceOwned(u8, allocator, stream.thumbnail_url, "{width}", "1920"); + thumbnail = try std.mem.replaceOwned(u8, allocator, thumbnail, "{height}", "1080"); + + var stream_url = std.ArrayList(u8).init(allocator); + _ = try stream_url.appendSlice("https://twitch.tv/"); + _ = try stream_url.appendSlice(stream.user_login); + + var icon_url: ?[]u8 = undefined; + var stm = try db.prepare("SELECT imageUrlStreamer FROM STREAMER WHERE idStreamer = ?"); + try stm.bind(1, sqlite.U8Array.text(stream.user_id)); + if (stm.next()) { + var res = sqlite.U8Array.text(undefined); + try stm.fetch(.{&res}); + + icon_url = try allocator.alloc(u8, res.text.len); + std.mem.copy(u8, icon_url.?, res.text); + stm.finalize(); + } else { + icon_url = null; + } + + return webhook.Embed{ + .title = stream.title, + .image = .{ + .url = thumbnail, + }, + .author = .{ + .name = stream.user_name, + .url = stream_url.items, + .icon_url = icon_url, + }, + .color = 0xa970ff, + .fields = fields.toOwnedSlice(), + }; + } else { + try insertMetadatas(db, stream); + return null; + } +} + +fn streamExist(db: *sqlite.Database, streamId: []const u8) anyerror!bool { + var stm = try db.prepare("SELECT \"foo\" FROM STREAM WHERE idStream = ?"); + try stm.bind(1, sqlite.U8Array.text(streamId)); + + const res = stm.next(); + stm.finalize(); + + return res; +} + +fn insertStream(db: *sqlite.Database, stream: *const twitch.Stream) anyerror!void { + var stm = try db.prepare( + "INSERT INTO STREAM(idStream, idStreamer, isMatureStream) VALUES(?, ?, ?)", + ); + + try stm.bind(1, sqlite.U8Array.text(stream.id)); + try stm.bind(2, sqlite.U8Array.text(stream.user_id)); + try stm.bind(3, @boolToInt(stream.is_mature)); + + try stm.exec(); + stm.finalize(); +} + +pub fn insertMetadatas(db: *sqlite.Database, stream: *const twitch.Stream) anyerror!void { + var stm = try db.prepare( + "INSERT INTO VIEWER_COUNT_STREAM(viewerCount, dateViewerCount, idStream) VALUES(?, datetime(\"now\"), ?)", + ); + + try stm.bind(1, stream.viewer_count); + try stm.bind(2, sqlite.U8Array.text(stream.id)); + + try stm.exec(); + stm.finalize(); + + if (try mustInsertName(db, stream)) { + std.log.debug("inserting name : {s} ({s})", .{ stream.title, stream.id }); + stm = try db.prepare( + "INSERT INTO NAME_STREAM(nameStream, dateNameStream, idStream) VALUES(?, datetime(\"now\"), ?)", + ); + + try stm.bind(1, sqlite.U8Array.text(stream.title)); + try stm.bind(2, sqlite.U8Array.text(stream.id)); + + try stm.exec(); + stm.finalize(); + } + + stm = try db.prepare( + "INSERT OR IGNORE INTO GAME(gameId, gameName) VALUES(?, ?)", + ); + + try stm.bind(1, sqlite.U8Array.text(stream.game_id)); + try stm.bind(2, sqlite.U8Array.text(stream.game_name)); + + try stm.exec(); + stm.finalize(); + + stm = try db.prepare( + "INSERT INTO IS_STREAMING_GAME(gameId, streamId, dateGameStream) VALUES(?, ?, datetime(\"now\"))", + ); + + try stm.bind(1, sqlite.U8Array.text(stream.game_id)); + try stm.bind(2, sqlite.U8Array.text(stream.id)); + + try stm.exec(); + stm.finalize(); +} + +fn mustInsertName(db: *sqlite.Database, stream: *const twitch.Stream) anyerror!bool { + var stm = try db.prepare( + "SELECT nameStream != ? FROM NAME_STREAM WHERE idStream = ? ORDER BY dateNameStream DESC LIMIT 1", + ); + + try stm.bind(1, sqlite.U8Array.text(stream.title)); + try stm.bind(2, sqlite.U8Array.text(stream.id)); + + var res: c_int = 1; + if (stm.next()) { + try stm.fetch(.{&res}); + } + stm.finalize(); + return res == 1; +} + +fn insertOrReplaceStreamers( + allocator: std.mem.Allocator, + db: *sqlite.Database, + client: *Client, + config: *const Config, + headers: *std.StringHashMap([]const u8), +) anyerror!void { + var request = std.ArrayList(u8).init(allocator); + + try request.appendSlice("https://api.twitch.tv/helix/users?"); + defer { + request.deinit(); + } + + { + var i: u8 = 0; + while (i < config.user_logins.len) : (i += 1) { + if (i != 0) + try request.append('&'); + try request.appendSlice("login="); + try request.appendSlice(config.user_logins[i].user_login); + } + } + + try request.append(0); + + var streamers: twitch.TwitchRes([]const twitch.User) = try client.getJSON( + twitch.TwitchRes([]const twitch.User), + @ptrCast([*:0]const u8, request.items), + headers, + ); + + for (streamers.data) |streamer| { + var stm = try db.prepare("INSERT OR REPLACE INTO STREAMER(idStreamer, loginStreamer, nameStreamer, imageUrlStreamer) VALUES(?, ?, ?, ?)"); + try stm.bind(1, sqlite.U8Array.text(streamer.id)); + try stm.bind(2, sqlite.U8Array.text(streamer.login)); + try stm.bind(3, sqlite.U8Array.text(streamer.display_name)); + try stm.bind(4, sqlite.U8Array.text(streamer.profile_image_url)); + + try stm.exec(); + stm.finalize(); + } + + streamers.deinit(allocator); +} diff --git a/src/client.zig b/src/client.zig index c3e5f6b..a104a0b 100644 --- a/src/client.zig +++ b/src/client.zig @@ -14,15 +14,23 @@ const ArrayListReader = struct { pub const Client = struct { ptr: *cURL.CURL, - allocator: *mem.Allocator, + allocator: mem.Allocator, - pub fn init(allocator: *mem.Allocator) ?@This() { - const ptr = cURL.curl_easy_init() orelse return null; + pub fn init(allocator: mem.Allocator) anyerror!@This() { + const ptr = cURL.curl_easy_init() orelse return error.CURLEasyInitFailed; - return @This(){ + var self = @This(){ .ptr = ptr, .allocator = allocator, }; + + try self.restoreDefaultSettings(); + + return self; + } + + pub fn deinit(self: *@This()) void { + cURL.curl_easy_cleanup(self.ptr); } pub fn globalInit() anyerror!void { @@ -34,12 +42,7 @@ pub const Client = struct { cURL.curl_global_cleanup(); } - pub fn getJSON(self: *@This(), comptime T: type, url: [*:0]const u8, headers: ?*std.StringHashMap([]const u8)) anyerror!T { - var response_buffer = std.ArrayList(u8).init(self.allocator.*); - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_URL, url) != cURL.CURLE_OK) - return error.CURLPerformFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_HTTPGET, @as(c_long, 1)) != cURL.CURLE_OK) - return error.CURLPerformFailed; + pub fn restoreDefaultSettings(self: *@This()) anyerror!void { if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_NOPROGRESS, @as(c_long, 1)) != cURL.CURLE_OK) return error.CURLPerformFailed; if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_MAXREDIRS, @as(c_long, 50)) != cURL.CURLE_OK) @@ -47,6 +50,20 @@ pub const Client = struct { if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_TCP_KEEPALIVE, @as(c_long, 1)) != cURL.CURLE_OK) return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEFUNCTION, writeToArrayListCallback) != cURL.CURLE_OK) + return error.CURLSetOptFailed; + + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_READFUNCTION, readFromArrayListCallback) != cURL.CURLE_OK) + return error.CURLPerformFailed; + } + + pub fn getJSON(self: *@This(), comptime T: type, url: [*:0]const u8, headers: ?*std.StringHashMap([]const u8)) anyerror!T { + var response_buffer = std.ArrayList(u8).init(self.allocator); + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_URL, url) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_HTTPGET, @as(c_long, 1)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + var header_slist: [*c]cURL.curl_slist = null; if (headers) |header| { @@ -69,9 +86,6 @@ pub const Client = struct { return error.CURLSetOptFailed; } - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEFUNCTION, writeToArrayListCallback) != cURL.CURLE_OK) - return error.CURLSetOptFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEDATA, &response_buffer) != cURL.CURLE_OK) return error.CURLPerformFailed; @@ -81,10 +95,11 @@ pub const Client = struct { if (header_slist != null) cURL.curl_slist_free_all(header_slist); - var stream = json.TokenStream.init(response_buffer.toOwnedSlice()); + var content = response_buffer.items; + var stream = json.TokenStream.init(content); @setEvalBranchQuota(10_000); - const res = json.parse(T, &stream, .{ .allocator = self.allocator.*, .ignore_unknown_fields = true }); + const res = json.parse(T, &stream, .{ .allocator = self.allocator, .ignore_unknown_fields = true }); response_buffer.deinit(); @@ -92,22 +107,14 @@ pub const Client = struct { } pub fn postJSON(self: *@This(), url: []const u8, data: anytype, headers: ?std.StringHashMap([]const u8)) anyerror![]const u8 { - var post_buffer = std.ArrayList(u8).init(self.allocator.*); - var response_buffer = std.ArrayList(u8).init(self.allocator.*); + var post_buffer = std.ArrayList(u8).init(self.allocator); + var response_buffer = std.ArrayList(u8).init(self.allocator); var rawUrl = try self.allocator.allocSentinel(u8, url.len, 0); std.mem.copy(u8, rawUrl, url); if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_URL, rawUrl.ptr) != cURL.CURLE_OK) return error.CURLPerformFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_NOPROGRESS, @as(c_long, 1)) != cURL.CURLE_OK) - return error.CURLPerformFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_MAXREDIRS, @as(c_long, 50)) != cURL.CURLE_OK) - return error.CURLPerformFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_TCP_KEEPALIVE, @as(c_long, 1)) != cURL.CURLE_OK) - return error.CURLPerformFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_TCP_KEEPALIVE, @as(c_long, 1)) != cURL.CURLE_OK) - return error.CURLPerformFailed; var header_slist: [*c]cURL.curl_slist = null; @@ -136,9 +143,6 @@ pub const Client = struct { }) != cURL.CURLE_OK) return error.CURLPerformFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_READFUNCTION, readFromArrayListCallback) != cURL.CURLE_OK) - return error.CURLPerformFailed; - if (header_slist != null) { if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_HTTPHEADER, header_slist) != cURL.CURLE_OK) return error.CURLSetOptFailed; @@ -147,9 +151,6 @@ pub const Client = struct { return error.CURLSetOptFailed; } - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEFUNCTION, writeToArrayListCallback) != cURL.CURLE_OK) - return error.CURLSetOptFailed; - if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEDATA, &response_buffer) != cURL.CURLE_OK) return error.CURLPerformFailed; @@ -167,10 +168,6 @@ pub const Client = struct { return res; } - - pub fn deinit(self: *@This()) void { - cURL.curl_easy_cleanup(self.ptr); - } }; fn writeToArrayListCallback(data: *anyopaque, size: c_uint, nmemb: c_uint, user_data: *anyopaque) callconv(.C) c_uint { diff --git a/src/main.zig b/src/main.zig index 74e30ed..22e8803 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,116 +1,13 @@ const std = @import("std"); const json = std.json; -const twitch = @import("twitch.zig"); const Client = @import("client.zig").Client; -const webhook = @import("webhook.zig"); -const sqlite = @import("sqlite.zig"); +const App = @import("app.zig"); -const DATABASE_VERSION_CODE = 2; - -const CREATE_TABLES = - \\ CREATE TABLE VERSION - \\ ( - \\ versionCode INTEGER - \\ ); - \\ - \\ INSERT INTO VERSION(versionCode) - \\ VALUES (2); - \\ - \\ CREATE TABLE STREAMER - \\ ( - \\ idStreamer TEXT PRIMARY KEY NOT NULL, - \\ loginStreamer TEXT NOT NULL, - \\ nameStreamer TEXT NOT NULL, - \\ imageUrlStreamer TEXT - \\ ); - \\ - \\ CREATE TABLE STREAM - \\ ( - \\ idStream TEXT PRIMARY KEY NOT NULL, - \\ idStreamer TEXT NOT NULL, - \\ isMatureStream BOOLEAN NOT NULL DEFAULT 'F', - \\ CONSTRAINT FK_STREAM_STREAMER_ID FOREIGN KEY (idStreamer) REFERENCES STREAMER (idStreamer) - \\ ); - \\ - \\ CREATE TABLE VIEWER_COUNT_STREAM - \\ ( - \\ viewerCount INTEGER NOT NULL, - \\ dateViewerCount DATE NOT NULL, - \\ idStream TEXT NOT NULL, - \\ PRIMARY KEY (dateViewerCount, idStream), - \\ CONSTRAINT FK_VIEWER_COUNT_STREAM_ID FOREIGN KEY (idStream) REFERENCES STREAM (idStream) - \\ ); - \\ - \\ CREATE TABLE NAME_STREAM - \\ ( - \\ nameStream TEXT NOT NULL, - \\ dateNameStream DATE NOT NULL, - \\ idStream TEXT NOT NULL, - \\ PRIMARY KEY (dateNameStream, idStream), - \\ CONSTRAINT FK_NAME_STREAM_STREAM_ID FOREIGN KEY (idStream) REFERENCES STREAM (idStream) - \\ ); - \\ - \\ CREATE TABLE GAME - \\ ( - \\ gameId TEXT NOT NULL PRIMARY KEY, - \\ gameName TEXT - \\ ); - \\ - \\ CREATE TABLE IS_STREAMING_GAME - \\ ( - \\ gameId TEXT NOT NULL, - \\ streamId TEXT NOT NULL, - \\ dateGameStream DATE NOT NULL, - \\ PRIMARY KEY (gameId, streamId, dateGameStream), - \\ CONSTRAINT FK_GAME_STREAM_GAME_ID FOREIGN KEY (gameId) REFERENCES GAME (gameId), - \\ CONSTRAINT FK_GAME_STREAM_STREAM_ID FOREIGN KEY (streamId) REFERENCES STREAM (idStream) - \\ ); -; - -const DROP_TABLES = - \\ DROP TABLE IF EXISTS VERSION; - \\ DROP TABLE IF EXISTS NAME_STREAM; - \\ DROP TABLE IF EXISTS VIEWER_COUNT_STREAM; - \\ DROP TABLE IF EXISTS IS_STREAMING_GAME; - \\ DROP TABLE IF EXISTS STREAM; - \\ DROP TABLE IF EXISTS STREAMER; - \\ DROP TABLE IF EXISTS GAME; -; - -const Config = struct { - token: []const u8, - client_id: []const u8, - refresh_rate: u64, - user_logins: []const User, - webhook_url: []const u8, - - pub fn fromFile(allocator: std.mem.Allocator, path: []const u8) anyerror!@This() { - var file = try std.fs.cwd().openFile(path, .{ - .read = true, - .write = false, - }); - - var stat = try file.stat(); - const file_buffer = try allocator.alloc(u8, stat.size); - _ = try file.readAll(file_buffer); - file.close(); - - var stream = json.TokenStream.init(file_buffer); - - const res = json.parse(@This(), &stream, .{ .allocator = allocator }); - allocator.free(file_buffer); - - return res; - } -}; - -const User = struct { user_login: []u8 }; - -var wait_event = std.Thread.StaticResetEvent{}; +pub var app: ?App = null; fn handler_fn(_: c_int) callconv(.C) void { - wait_event.set(); + app.?.stop(); } fn setupSigIntHandler() void { @@ -128,318 +25,27 @@ fn setupSigIntHandler() void { } pub fn main() anyerror!void { - var db = try sqlite.Database.open("data.db"); - defer { - _ = db.close() catch |e| { - std.log.err("Failed to close db : {}", .{e}); - }; - } - setupSigIntHandler(); - try createTables(&db); - var general_purpose_allocator = std.heap.GeneralPurposeAllocator(.{}){}; var allocator = general_purpose_allocator.allocator(); - var config = try Config.fromFile(allocator, "config.json"); + app = try App.init(allocator); - try Client.globalInit(); + app.?.run() catch |e| { + std.log.err("Error while running app: {}", .{e}); + }; - var client = try Client.init(&allocator) orelse error.FailedInitClient; + const stdout = std.io.getStdOut().writer(); + stdout.print("stopping ...", .{}) catch {}; - var headers = std.StringHashMap([]const u8).init(allocator); - try headers.put("Authorization", config.token); - try headers.put("Client-Id", config.client_id); - - try insertOrReplaceStreamers(allocator, &db, &client, &config, &headers); - - var loop = true; - while (loop) { - var alertAllocator = std.heap.ArenaAllocator.init(allocator); - try updateAlert(alertAllocator.allocator(), &client, &config, &db, &headers); - alertAllocator.deinit(); - - std.log.info("sleeping for {} ns", .{config.refresh_rate * std.time.ns_per_ms}); - const res = wait_event.timedWait(config.refresh_rate * std.time.ns_per_ms); - loop = res == .timed_out; - } - - std.log.info("stopping ...", .{}); - - headers.deinit(); - client.deinit(); + _ = app.?.deinit() catch |e| { + std.log.err("Failed to deinit app: {}", .{e}); + }; try Client.cleanup(); if (general_purpose_allocator.deinit()) { std.log.err("leaked bytes", .{}); } } - -pub fn createTables(db: *sqlite.Database) anyerror!void { - var stm = db.prepare("SELECT versionCode FROM VERSION ORDER BY versionCode DESC") catch { - std.log.debug("Creating database", .{}); - - try db.exec(CREATE_TABLES); - return; - }; - - if (stm.next()) { - var code: isize = 0; - - try stm.fetch(.{&code}); - stm.finalize(); - - if (DATABASE_VERSION_CODE == code) { - std.log.debug("Database already created", .{}); - return; - } else { - try db.exec(DROP_TABLES); - std.log.debug("Creating database", .{}); - - try db.exec(CREATE_TABLES); - } - } - stm.finalize(); -} - -pub fn updateAlert( - allocator: std.mem.Allocator, - client: *Client, - config: *Config, - database: *sqlite.Database, - headers: *std.StringHashMap([]const u8), -) anyerror!void { - var request = std.ArrayList(u8).init(allocator); - - try request.appendSlice("https://api.twitch.tv/helix/streams?"); - - { - var i: u8 = 0; - while (i < config.user_logins.len) : (i += 1) { - if (i != 0) - try request.append('&'); - try request.appendSlice("user_login="); - try request.appendSlice(config.user_logins[i].user_login); - } - } - - try request.append(0); - - const streams: twitch.TwitchRes([]const twitch.Stream) = try client.getJSON( - twitch.TwitchRes([]const twitch.Stream), - @ptrCast([*:0]const u8, request.items), - headers, - ); - - request.deinit(); - - if (streams.data.len > 0) { - var embeds = std.ArrayList(webhook.Embed).init(allocator); - - for (streams.data) |s| { - if (try appendEmbed(allocator, &s, database)) |e| { - std.log.info("{s}", .{s.title}); - try embeds.append(e); - } - } - - if (embeds.items.len > 0) { - var res = try client.postJSON(config.webhook_url, webhook.Webhook{ - .username = "Twitch", - .content = "Live alert", - .embeds = embeds.items, - }, null); - - client.allocator.free(res); - } - embeds.deinit(); - } -} - - -fn appendEmbed(allocator: std.mem.Allocator, stream: *const twitch.Stream, db: *sqlite.Database) anyerror!?webhook.Embed { - if (!try streamExist(db, stream.id)) { - try insertStream(db, stream); - try insertMetadatas(db, stream); - - var fields = std.ArrayList(webhook.Field).init(allocator); // TODO BETTER WAY - var viewer = std.ArrayList(u8).init(allocator); - try std.fmt.format(viewer.writer(), "{}", .{stream.viewer_count}); - - try fields.append(.{ - .name = "Viewer count", - .value = viewer.toOwnedSlice(), - .@"inline" = true, - }); - try fields.append(.{ - .name = "Game name", - .value = stream.game_name, - .@"inline" = true, - }); - - var thumbnail = try std.mem.replaceOwned(u8, allocator, stream.thumbnail_url, "{width}", "1920"); - thumbnail = try std.mem.replaceOwned(u8, allocator, thumbnail, "{height}", "1080"); - - var stream_url = std.ArrayList(u8).init(allocator); - _ = try stream_url.appendSlice("https://twitch.tv/"); - _ = try stream_url.appendSlice(stream.user_login); - - var icon_url: ?[]u8 = undefined; - var stm = try db.prepare("SELECT imageUrlStreamer FROM STREAMER WHERE idStreamer = ?"); - try stm.bind(1, sqlite.U8Array.text(stream.user_id)); - if (stm.next()) { - var res = sqlite.U8Array.text(undefined); - try stm.fetch(.{&res}); - - icon_url = try allocator.alloc(u8, res.text.len); - std.mem.copy(u8, icon_url.?, res.text); - stm.finalize(); - } else { - icon_url = null; - } - - return webhook.Embed{ - .title = stream.title, - .image = .{ - .url = thumbnail, - }, - .author = .{ - .name = stream.user_name, - .url = stream_url.items, - .icon_url = icon_url, - }, - .color = 0xa970ff, - .fields = fields.toOwnedSlice(), - }; - } else { - try insertMetadatas(db, stream); - return null; - } -} - -fn streamExist(db: *sqlite.Database, streamId: []const u8) anyerror!bool { - var stm = try db.prepare("SELECT \"foo\" FROM STREAM WHERE idStream = ?"); - try stm.bind(1, sqlite.U8Array.text(streamId)); - - const res = stm.next(); - stm.finalize(); - - return res; -} - -fn insertStream(db: *sqlite.Database, stream: *const twitch.Stream) anyerror!void { - var stm = try db.prepare( - "INSERT INTO STREAM(idStream, idStreamer, isMatureStream) VALUES(?, ?, ?)", - ); - - try stm.bind(1, sqlite.U8Array.text(stream.id)); - try stm.bind(2, sqlite.U8Array.text(stream.user_id)); - try stm.bind(3, @boolToInt(stream.is_mature)); - - try stm.exec(); - stm.finalize(); -} - -pub fn insertMetadatas(db: *sqlite.Database, stream: *const twitch.Stream) anyerror!void { - var stm = try db.prepare( - "INSERT INTO VIEWER_COUNT_STREAM(viewerCount, dateViewerCount, idStream) VALUES(?, datetime(\"now\"), ?)", - ); - - try stm.bind(1, stream.viewer_count); - try stm.bind(2, sqlite.U8Array.text(stream.id)); - - try stm.exec(); - stm.finalize(); - - if (try mustInsertName(db, stream)) { - std.log.debug("inserting name : {s} ({s})", .{stream.title, stream.id}); - stm = try db.prepare( - "INSERT INTO NAME_STREAM(nameStream, dateNameStream, idStream) VALUES(?, datetime(\"now\"), ?)", - ); - - try stm.bind(1, sqlite.U8Array.text(stream.title)); - try stm.bind(2, sqlite.U8Array.text(stream.id)); - - try stm.exec(); - stm.finalize(); - } - - stm = try db.prepare( - "INSERT OR IGNORE INTO GAME(gameId, gameName) VALUES(?, ?)", - ); - - try stm.bind(1, sqlite.U8Array.text(stream.game_id)); - try stm.bind(2, sqlite.U8Array.text(stream.game_name)); - - try stm.exec(); - stm.finalize(); - - stm = try db.prepare( - "INSERT INTO IS_STREAMING_GAME(gameId, streamId, dateGameStream) VALUES(?, ?, datetime(\"now\"))", - ); - - try stm.bind(1, sqlite.U8Array.text(stream.game_id)); - try stm.bind(2, sqlite.U8Array.text(stream.id)); - - try stm.exec(); - stm.finalize(); -} - -fn mustInsertName(db: *sqlite.Database, stream: *const twitch.Stream) anyerror!bool { - var stm = try db.prepare( - "SELECT nameStream != ? FROM NAME_STREAM WHERE idStream = ? ORDER BY dateNameStream DESC LIMIT 1", - ); - - try stm.bind(1, sqlite.U8Array.text(stream.title)); - try stm.bind(2, sqlite.U8Array.text(stream.id)); - - var res: c_int = 1; - if (stm.next()) { - try stm.fetch(.{&res}); - } - stm.finalize(); - return res == 1; -} - -fn insertOrReplaceStreamers( - allocator: std.mem.Allocator, - db: *sqlite.Database, - client: *Client, - config: *const Config, - headers: *std.StringHashMap([]const u8), -) anyerror!void { - var request = std.ArrayList(u8).init(allocator); - - try request.appendSlice("https://api.twitch.tv/helix/users?"); - - { - var i: u8 = 0; - while (i < config.user_logins.len) : (i += 1) { - if (i != 0) - try request.append('&'); - try request.appendSlice("login="); - try request.appendSlice(config.user_logins[i].user_login); - } - } - - try request.append(0); - - const streamers: twitch.TwitchRes([]const twitch.User) = try client.getJSON( - twitch.TwitchRes([]const twitch.User), - @ptrCast([*:0]const u8, request.items), - headers, - ); - - for (streamers.data) |streamer| { - var stm = try db.prepare("INSERT OR REPLACE INTO STREAMER(idStreamer, loginStreamer, nameStreamer, imageUrlStreamer) VALUES(?, ?, ?, ?)"); - try stm.bind(1, sqlite.U8Array.text(streamer.id)); - try stm.bind(2, sqlite.U8Array.text(streamer.login)); - try stm.bind(3, sqlite.U8Array.text(streamer.display_name)); - try stm.bind(4, sqlite.U8Array.text(streamer.profile_image_url)); - - try stm.exec(); - stm.finalize(); - } -} diff --git a/src/twitch.zig b/src/twitch.zig index ddb9154..c3acd6d 100644 --- a/src/twitch.zig +++ b/src/twitch.zig @@ -1,3 +1,6 @@ +const std = @import("std"); +const mem = std.mem; + pub const Stream = struct { id: []const u8, user_id: []const u8, @@ -25,5 +28,80 @@ pub const User = struct { pub const Pagination = struct { cursor: ?[]u8 = null }; pub fn TwitchRes(comptime T: type) type { - return struct { data: T, pagination: ?Pagination = null}; + return struct { + data: T, + pagination: ?Pagination = null, + pub fn deinit(self: *@This(), allocator: mem.Allocator) void { + deinitVar(allocator, self.data); + if (self.pagination) |p| { + if (p.cursor) |cursor| { + allocator.free(cursor); + } + } + self.* = undefined; + } + }; +} + +inline fn deinitVar(allocator: mem.Allocator, value: anytype) void { + const T = @TypeOf(value); + + if (comptime std.meta.trait.hasFn("deinit")(T)) { + value.deinit(); // TODO pass allocator if needed + } else { + switch (@typeInfo(T)) { + .Optional => { + if (value) |payload| { + deinitVar(allocator, payload); + } + }, + .Union => { + const info = @typeInfo(T).Union; + if (info.tag_type) |UnionTagType| { + inline for (info.fields) |u_field| { + if (value == @field(UnionTagType, u_field.name)) { + deinitVar(allocator, @field(value, u_field.name)); + } + } + } + }, + .Struct => |S| { + inline for (S.fields) |Field| { + deinitVar(allocator, @field(value, Field.name)); + } + }, + .Pointer => |ptr_info| switch (ptr_info.size) { + .One => switch (@typeInfo(ptr_info.child)) { + .Array => { + const Slice = []const std.meta.Elem(ptr_info.child); + return deinitVar(allocator, @as(Slice, value)); + }, + else => { + deinitVar(allocator, value.*); + allocator.destroy(value); + }, + }, + .Slice => { + const elem = std.meta.Elem(T); + switch (@typeInfo(elem)) { + .Type, .Void, .Bool, .Int, .Float, .Enum => { + //AVOID USELESS LOOPING + }, + else => { + var i: usize = 0; + while (i < value.len) : (i += 1) { + deinitVar(allocator, value[i]); + } + }, + } + + allocator.free(value); + }, + else => @compileError("Unable to deinit type '" ++ @typeName(T) ++ "'"), + }, + .Array => deinitVar(allocator, &value), + .Type, .Void, .Bool, .Int, .Float, .Enum => {}, + else => @compileError("Unable to deinit type '" ++ @typeName(T) ++ "'"), + } + } }