diff --git a/.github/workflows/TwitchNotify.yml b/.github/workflows/TwitchNotify.yml new file mode 100644 index 0000000..cafdcb3 --- /dev/null +++ b/.github/workflows/TwitchNotify.yml @@ -0,0 +1,40 @@ +name: TwitchNotify + +on: + push: + branches: master + +jobs: + build: + runs-on: windows-2022 + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + path: repo + + - name: Build + shell: cmd + run: cd repo && build.cmd + + - name: Checkout wiki + uses: actions/checkout@v3 + with: + repository: ${{github.repository}}.wiki + path: wiki + + - name: Upload binary + shell: cmd + run: | + cd repo + "C:\Program Files\7-Zip\7z.exe" a -tzip -mx=9 TwitchNotify.zip TwitchNotify.exe TwitchNotify.ini + cd .. + + copy repo\TwitchNotify.zip wiki + + cd wiki + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit --all --amend --no-edit + git push --force-with-lease diff --git a/TwitchNotify.c b/TwitchNotify.c index 2215319..1674b41 100644 --- a/TwitchNotify.c +++ b/TwitchNotify.c @@ -35,6 +35,7 @@ // sent when websocket is connected or disconnected // WParam contains HINTERNET handle for websocket, or NULL when disconnected +// LParam contains true if this is first time websocket connected #define WM_TWITCH_NOTIFY_WEBSOCKET (WM_USER + 3) // sent when websocked receives live status update for user @@ -53,9 +54,13 @@ // WParam is JsonObject for response (must release it) #define WM_TWITCH_NOTIFY_USER_INFO (WM_USER + 7) +// sent when user stream status is downloaded +// WParam is JsonObject for response (must release it) +#define WM_TWITCH_NOTIFY_USER_STREAM_INFO (WM_USER + 8) + // sent when list of followed users is download // WParam is JsonObject for response (must release it) -#define WM_TWITCH_NOTIFY_FOLLOWED_USERS (WM_USER + 8) +#define WM_TWITCH_NOTIFY_FOLLOWED_USERS (WM_USER + 9) // command id's for popup menu items #define CMD_OPEN_HOMEPAGE 10 // "Twitch Notify" item selected @@ -357,7 +362,7 @@ static void WebsocketPing(void) WinHttpWebSocketSend(State.Websocket, WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE, Data, ARRAYSIZE(Data) - 1); } -static void WesocketListenUser(int UserId, bool Listen) +static void WebsocketListenUser(int UserId, bool Listen) { if (State.Websocket) { @@ -414,7 +419,7 @@ static int DoGqlQuery(char* Query, int QuerySize, char* Buffer, int BufferSize) return ReadSize; } -// gets "unqiue" path on disk to image - do this by hashing image URL +// gets "unique" path on disk to image - do this by hashing image URL // image will be placed in %TEMP% folder static void GetImagePath(LPWSTR ImagePath, LPCWSTR ImageUrl) @@ -635,7 +640,7 @@ static void LoadUsers(void) if (OldUser->UserId > 0) { // unsubscribe from websocket notifications - WesocketListenUser(OldUser->UserId, false); + WebsocketListenUser(OldUser->UserId, false); } // in case notification game/stream title update was pending // update it and release notification handle @@ -816,6 +821,11 @@ static void DownloadUserStream(int UserId, int Delay) static void ShowTrayMenu(HWND Window) { + bool MpvFound = IsMpvInPath(); + WCHAR username[MAX_STRING_LENGTH]; + + bool CanUpdateUsers = GetPrivateProfileStringW(L"twitch", L"username", L"", username, ARRAYSIZE(username), State.IniPath) && username[0]; + HMENU QualityMenu = CreatePopupMenu(); Assert(QualityMenu); @@ -825,8 +835,12 @@ static void ShowTrayMenu(HWND Window) AppendMenuW(QualityMenu, Flags, CMD_QUALITY + Index, Quality[Index].Name); } - HMENU UsersMenu = CreatePopupMenu(); - Assert(UsersMenu); + HMENU Menu = CreatePopupMenu(); + Assert(Menu); + + AppendMenuW(Menu, MF_STRING, CMD_OPEN_HOMEPAGE, L"Twitch Notify"); + AppendMenuW(Menu, MF_SEPARATOR, 0, NULL); + for (int Index = 0; Index < State.UserCount; Index++) { @@ -838,40 +852,32 @@ static void ShowTrayMenu(HWND Window) { WCHAR Caption[1024]; wsprintfW(Caption, L"%s\t%d", Name, User->ViewerCount); - AppendMenuW(UsersMenu, MF_CHECKED, CMD_USER + Index, Caption); + AppendMenuW(Menu, MF_CHECKED, CMD_USER + Index, Caption); } else { - AppendMenuW(UsersMenu, MF_STRING, CMD_USER + Index, Name); + AppendMenuW(Menu, MF_STRING, CMD_USER + Index, Name); } } else // unknown user { - AppendMenuW(UsersMenu, MF_GRAYED, 0, User->Name); + AppendMenuW(Menu, MF_GRAYED, 0, User->Name); } } if (State.UserCount == 0) { - AppendMenuW(UsersMenu, MF_GRAYED, 0, L"No users"); + AppendMenuW(Menu, MF_GRAYED, 0, L"No users"); } - WCHAR username[MAX_STRING_LENGTH]; - bool CanDownload = GetPrivateProfileStringW(L"twitch", L"username", L"", username, ARRAYSIZE(username), State.IniPath) && username[0]; - - AppendMenuW(UsersMenu, MF_SEPARATOR, 0, NULL); - AppendMenuW(UsersMenu, MF_STRING | (CanDownload ? 0 : MF_GRAYED), CMD_DOWNLOAD_USERS, L"Download"); - AppendMenuW(UsersMenu, MF_STRING, CMD_EDIT_USERS, L"Edit"); - - HMENU Menu = CreatePopupMenu(); - Assert(Menu); + AppendMenuW(Menu, MF_SEPARATOR, 0, NULL); - bool MpvFound = IsMpvInPath(); + AppendMenuW(Menu, MF_STRING | (CanUpdateUsers ? 0 : MF_GRAYED), CMD_DOWNLOAD_USERS, L"Download User List"); + AppendMenuW(Menu, MF_STRING, CMD_EDIT_USERS, L"Edit User List"); - AppendMenuW(Menu, MF_STRING, CMD_OPEN_HOMEPAGE, L"Twitch Notify"); AppendMenuW(Menu, MF_SEPARATOR, 0, NULL); - AppendMenuW(Menu, (State.UseMpv ? MF_CHECKED : MF_STRING) | (MpvFound ? 0 : MF_GRAYED), CMD_USE_MPV, L"Use mpv"); - AppendMenuW(Menu, MF_POPUP | (MpvFound ? 0 : MF_GRAYED), (UINT_PTR)QualityMenu, L"Quality"); - AppendMenuW(Menu, MF_POPUP, (UINT_PTR)UsersMenu, L"Users"); + + AppendMenuW(Menu, (State.UseMpv ? MF_CHECKED : MF_STRING) | (MpvFound ? 0 : MF_GRAYED), CMD_USE_MPV, L"mpv Playback"); + AppendMenuW(Menu, MF_POPUP | (MpvFound ? 0 : MF_GRAYED), (UINT_PTR)QualityMenu, L"mpv Quality"); AppendMenuW(Menu, MF_SEPARATOR, 0, NULL); AppendMenuW(Menu, MF_STRING, CMD_EXIT, L"Exit"); @@ -925,11 +931,24 @@ static void ShowTrayMenu(HWND Window) } DestroyMenu(Menu); - DestroyMenu(UsersMenu); DestroyMenu(QualityMenu); } -static void OnWebsocketConnected(HINTERNET Websocket) +static void CALLBACK DownloadUserStreamInfoWork(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work) +{ + char* Query = (char*)Context; + + char Buffer[65536]; + int BufferSize = DoGqlQuery(Query, lstrlenA(Query), Buffer, sizeof(Buffer)); + LocalFree(Query); + + JsonObject* Json = JsonObject_Parse(Buffer, BufferSize); + PostMessageW(State.Window, WM_TWITCH_NOTIFY_USER_STREAM_INFO, (WPARAM)Json, 0); + + CloseThreadpoolWork(Work); +} + +static void OnWebsocketConnected(HINTERNET Websocket, BOOL FirstConnection) { State.Websocket = Websocket; UpdateTrayIcon(State.Window, Websocket ? State.Icon : State.IconDisconnected); @@ -945,14 +964,62 @@ static void OnWebsocketConnected(HINTERNET Websocket) User* User = &State.Users[Index]; if (User->UserId > 0) { - WesocketListenUser(User->UserId, true); + WebsocketListenUser(User->UserId, true); } } + + // query if users streams are online + // don't need to do that on first connection, because that is done as part of .ini file load + if (!FirstConnection) + { + WCHAR Query[4096]; + int QuerySize = 0; + + QuerySize = StrCatChainW(Query, ARRAYSIZE(Query), QuerySize, L"{\"query\":\"{users(logins:["); + + LPCWSTR Delim = L""; + + for (int Index = 0; Index < State.UserCount; Index++) + { + User* User = &State.Users[Index]; + + QuerySize = StrCatChainW(Query, ARRAYSIZE(Query), QuerySize, Delim); + QuerySize = StrCatChainW(Query, ARRAYSIZE(Query), QuerySize, L"\\\""); + QuerySize = StrCatChainW(Query, ARRAYSIZE(Query), QuerySize, User->Name); + QuerySize = StrCatChainW(Query, ARRAYSIZE(Query), QuerySize, L"\\\""); + Delim = L","; + } + + QuerySize = StrCatChainW(Query, ARRAYSIZE(Query), QuerySize, L"]){id,stream{viewersCount}}}\"}"); + + char QueryBytes[4096]; + QuerySize = WideCharToMultiByte(CP_UTF8, 0, Query, QuerySize, QueryBytes, ARRAYSIZE(QueryBytes), NULL, NULL); + QueryBytes[QuerySize] = 0; + + // queue gql query to background + + TP_CALLBACK_ENVIRON Environ; + InitializeThreadpoolEnvironment(&Environ); + SetThreadpoolCallbackPool(&Environ, State.ThreadPool); + + // NOTE: callback must LocalFree() passed context pointer + PTP_WORK Work = CreateThreadpoolWork(&DownloadUserStreamInfoWork, StrDupA(QueryBytes), &Environ); + Assert(Work); + + SubmitThreadpoolWork(Work); + } } else { // when websocket is disconnected, stop PING message timer KillTimer(State.Window, TIMER_WEBSOCKET_PING); + + // mark all users offline + for (int Index = 0; Index < State.UserCount; Index++) + { + User* User = &State.Users[Index]; + User->IsLive = false; + } } } @@ -1113,7 +1180,7 @@ static void OnUserInfo(JsonObject* Json) // subscribe to user live events on websocket // if websocket is not connected yet, this subscription will // happen on WM_TWITCH_NOTIFY_WEBSOCKET message - WesocketListenUser(User->UserId, true); + WebsocketListenUser(User->UserId, true); WindowsDeleteString(UserId); WindowsDeleteString(DisplayName); @@ -1126,6 +1193,69 @@ static void OnUserInfo(JsonObject* Json) JsonRelease(Data); } +static void OnUserStreamInfo(JsonObject* Json) +{ + JsonArray* Errors = JsonObject_GetArray(Json, JsonCSTR("errors")); + if (Errors) + { + JsonObject* ErrorMessage = JsonArray_GetObject(Errors, 0); + HSTRING Message = JsonObject_GetString(ErrorMessage, JsonCSTR("message")); + ShowTrayMessage(State.Window, NIIF_ERROR, WindowsGetStringRawBuffer(Message, NULL)); + WindowsDeleteString(Message); + JsonRelease(ErrorMessage); + JsonRelease(Errors); + return; + } + + JsonObject* Data = JsonObject_GetObject(Json, JsonCSTR("data")); + JsonArray* Users = JsonObject_GetArray(Data, JsonCSTR("users")); + + int UserIndex = 0; + int UsersCount = JsonArray_GetCount(Users); + for (int Index = 0; Index < UsersCount; Index++) + { + JsonObject* UserData = JsonArray_GetObject(Users, Index); + if (!UserData) + { + continue; + } + + HSTRING UserIdStr = JsonObject_GetString(UserData, JsonCSTR("id")); + int UserId = StrToIntW(WindowsGetStringRawBuffer(UserIdStr, NULL)); + + User* User = NULL; + for (int UserIndex = 0; UserIndex < State.UserCount; UserIndex++) + { + if (State.Users[UserIndex].UserId == UserId) + { + User = &State.Users[UserIndex]; + break; + } + } + + if (User) + { + JsonObject* Stream = JsonObject_GetObject(UserData, JsonCSTR("stream")); + if (Stream) + { + User->ViewerCount = (int)JsonObject_GetNumber(Stream, JsonCSTR("viewersCount")); + User->IsLive = Stream != NULL; + } + else + { + User->ViewerCount = 0; + User->IsLive = false; + } + JsonRelease(Stream); + } + + WindowsDeleteString(UserIdStr); + JsonRelease(UserData); + } + JsonRelease(Users); + JsonRelease(Data); +} + static void OnFollowedUsers(JsonObject* Json) { JsonArray* Errors = JsonObject_GetArray(Json, JsonCSTR("errors")); @@ -1230,7 +1360,8 @@ static LRESULT CALLBACK WindowProc(HWND Window, UINT Message, WPARAM WParam, LPA { // websocket connected or disconnected HINTERNET Websocket = (HINTERNET)WParam; - OnWebsocketConnected(Websocket); + bool FirstConnection = (bool)LParam; + OnWebsocketConnected(Websocket, FirstConnection); return 0; } else if (Message == WM_TWITCH_NOTIFY_USER_STATUS) @@ -1266,6 +1397,14 @@ static LRESULT CALLBACK WindowProc(HWND Window, UINT Message, WPARAM WParam, LPA JsonRelease(Json); return 0; } + else if (Message == WM_TWITCH_NOTIFY_USER_STREAM_INFO) + { + // user live info received from gql query + JsonObject* Json = (JsonObject*)WParam; + OnUserStreamInfo(Json); + JsonRelease(Json); + return 0; + } else if (Message == WM_TWITCH_NOTIFY_FOLLOWED_USERS) { // process list of followed users @@ -1288,6 +1427,23 @@ static LRESULT CALLBACK WindowProc(HWND Window, UINT Message, WPARAM WParam, LPA return 0; } } + else if (Message == WM_POWERBROADCAST) + { + // resuming from sleep/hibernate + if (WParam == PBT_APMRESUMEAUTOMATIC) + { + // set all users to be offline + for (int Index = 0; Index < State.UserCount; Index++) + { + State.Users[Index].IsLive = false; + } + + // disconnect websocket, it will reconnect automatically + WinHttpWebSocketClose(State.Websocket, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, NULL, 0); + WinHttpCloseHandle(State.Websocket); + } + return TRUE; + } else if (Message == WM_TASKBARCREATED) { AddTrayIcon(Window, State.Websocket ? State.Icon : State.IconDisconnected); @@ -1385,6 +1541,7 @@ static DWORD WINAPI WebsocketThread(LPVOID Arg) const int DefaultDelay = 1000; // 1 second default delay const int MaxDelay = 60 * 1000; // max delay = 1 minute int Delay = DefaultDelay; + bool FirstConnection = true; for (;;) { HINTERNET Connection = WinHttpConnect(State.Session, L"pubsub-edge.twitch.tv", INTERNET_DEFAULT_HTTPS_PORT, 0); @@ -1406,7 +1563,7 @@ static DWORD WINAPI WebsocketThread(LPVOID Arg) Request = NULL; Delay = DefaultDelay; - PostMessageW(State.Window, WM_TWITCH_NOTIFY_WEBSOCKET, (WPARAM)Websocket, 0); + PostMessageW(State.Window, WM_TWITCH_NOTIFY_WEBSOCKET, (WPARAM)Websocket, (LPARAM)FirstConnection); WebsocketLoop(Websocket); PostMessageW(State.Window, WM_TWITCH_NOTIFY_WEBSOCKET, 0, 0); @@ -1425,6 +1582,7 @@ static DWORD WINAPI WebsocketThread(LPVOID Arg) Sleep(Delay); Delay = min(Delay * 2, MaxDelay); + FirstConnection = false; } } @@ -1446,7 +1604,11 @@ static void OnToastActivated(WindowsToast* Toast, void* Item, LPCWSTR Action) } } +#ifdef _DEBUG +int wWinMain(HINSTANCE Instance, HINSTANCE PrevInstance, LPWSTR CmdLine, int ShowCmd) +#else void WinMainCRTStartup(void) +#endif { WNDCLASSEXW WindowClass = { diff --git a/TwitchNotify.ini b/TwitchNotify.ini index 0f60b3a..06cb0db 100644 --- a/TwitchNotify.ini +++ b/TwitchNotify.ini @@ -8,19 +8,17 @@ autoupdate=0 [users] ; list of users to notify about, each on separate line -handmade_hero +molly_rocket +handmadenetwork cmuratori j_blow -gamesdonequick miblo martincohen sssmcgrath mr4thdimention mvandevander -steelgolem abnercoimbre nothings2 -ginger_bill asafgartner rygorous pervognsen diff --git a/build.cmd b/build.cmd index de1daa9..18414cd 100644 --- a/build.cmd +++ b/build.cmd @@ -17,8 +17,8 @@ if "%VSCMD_ARG_TGT_ARCH%" neq "x64" ( ) if "%1" equ "debug" ( - set CL=/MTd /Od /Zi /D_DEBUG /RTC1 /FdTwitchNotify.pdb /fsanitize=address - set LINK=/DEBUG libucrtd.lib libvcruntimed.lib + set CL=/MDd /Od /Zi /D_DEBUG /RTC1 /FdTwitchNotify.pdb /fsanitize=address + set LINK=/DEBUG ) else ( set CL=/O1 /DNDEBUG /GS- set LINK=/OPT:REF /OPT:ICF libvcruntime.lib