yt/twitch fixes 2026

This commit is contained in:
Luxferre
2026-03-07 09:44:35 +02:00
parent 1d317247ca
commit d0993a0910
32 changed files with 28191 additions and 86 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ This is the main StreamGoose component. It provides three main functions:
- providing the unified message queue for the frontend side consumption; - providing the unified message queue for the frontend side consumption;
- serving static files that make up the frontend (`index.html`, `script.js`, `style.css` and images) over the plain HTTP protocol. - serving static files that make up the frontend (`index.html`, `script.js`, `style.css` and images) over the plain HTTP protocol.
Additionally, the server binary also launches the backends it knows about and opens a simple GUI window to show the Web content locally, feeding its unified output into the JS side via server-sent events. The latter part became possible thanks to the [webview_go](https://pkg.go.dev/centrifuge.hectabit.org/HectaBit/webview_go) library. Additionally, the server binary also launches the backends it knows about and opens a simple GUI window to show the Web content locally, feeding its unified output into the JS side via server-sent events. The latter part became possible thanks to the [webview_go](https://github.com/webview/webview_go) library.
### Backends ### Backends
+62
View File
@@ -0,0 +1,62 @@
name: CI Pipeline
on: [push, pull_request]
jobs:
build:
runs-on: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
# Go 1.23 was the latest version as of 2024-08-31 but the action output a "warning":
# Failed to restore: "/usr/bin/tar" failed with error: The process '/usr/bin/tar' failed with exit code 2
# Go 1.22 didn't output the above "warning" so we're using this version.
# Go 1.14 made -mod=vendor default.
# Go 1.13 is the version set in go.mod.
include:
- { image: macos-14, go: '1.22' }
- { image: macos-14, go: '1.21' }
- { image: macos-14, go: '1.20' }
# Go 1.19 is the earliest version available for ARM64.
- { image: macos-14, go: '1.19' }
# macos-12 runner has x86_64 support which is needed for old versions of Go
- { image: macos-12, go: '1.18' }
- { image: macos-12, go: '1.17' }
- { image: macos-12, go: '1.16' }
- { image: macos-12, go: '1.15' }
- { image: macos-12, go: '1.14' }
- { image: macos-12, go: '1.13' }
- { image: ubuntu-22.04, go: '1.22', apt: libgtk-4-dev libwebkitgtk-6.0-dev }
- { image: ubuntu-22.04, go: '1.22', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.22', apt: libgtk-3-dev libwebkit2gtk-4.0-dev }
- { image: ubuntu-22.04, go: '1.21', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.20', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.19', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.18', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.17', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.16', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.15', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.14', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: ubuntu-22.04, go: '1.13', apt: libgtk-3-dev libwebkit2gtk-4.1-dev }
- { image: windows-2022, go: '1.22' }
- { image: windows-2022, go: '1.21' }
# Go 1.20 is the earliest version that is usable in this environment as of 2024-08-31.
# Compilation fails with older versions:
# [...]/bin/ld.exe: [...]\go-link-3209855222\000008.o: in function `x_cgo_thread_start':
# \\_\_\runtime\cgo/gcc_util.c:18: undefined reference to `__imp___iob_func'
- { image: windows-2022, go: '1.20' }
name: Build (${{ matrix.image }}, go ${{ matrix.go }})
steps:
- uses: actions/checkout@v4
- name: Install dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.0-dev
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Build examples
run: >
go build
./examples/basic
./examples/bind
- name: Run tests
run: go test
+15
View File
@@ -0,0 +1,15 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
+15
View File
@@ -0,0 +1,15 @@
# Changelog
### Migrating from v0.1.1 to v0.10.0
1. `Webview.Open()` has been removed. Use other webview APIs to create a window, open a link and run main UI loop.
2. `Webview.Debug()` and `webview.Debugf()` have been removed. Use your favorite logging library to debug webview apps.
3. `Webview.Settings` struct has been removed. Title, URL and size are controlled via other API setters and can be updated at any time, not only when webview is created.
4. `Webview.Loop()` has been removed. Use `Run()` instead.
5. `WebView.Run()`, `WebView.Terminate()`, `WebView.SetTitle()`, `WebView.Dispatch()` stayed the same.
6. `WebView.Exit()` has been renamed to `WebView.Destroy()`
7. `WebView.SetColor()` and `WebView.SetFullScreen()` have been removed. Use `Window()` to get native window handle and probably write some Cgo code to adjust native window to your taste.
8. `Webview.Dialog` has been removed. But it is likely to be brought back as a standalone module.
9. `WebView.Eval()` remained the same.
10. `WebView.InjectCSS()` has been removed. Use eval to inject style tag with CSS inside.
11. `WebView.Bind()` kept the name, but changed the semantics. Only functions can be bound. Not the structs, like in Lorca.
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2017 Serge Zaitsev
Copyright (c) 2020 webview
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.
+50
View File
@@ -0,0 +1,50 @@
# webview_go
[![GoDoc](https://godoc.org/github.com/webview/webview_go?status.svg)](https://godoc.org/github.com/webview/webview_go)
[![Go Report Card](https://goreportcard.com/badge/github.com/webview/webview_go)](https://goreportcard.com/report/github.com/webview/webview_go)
Go language binding for the [webview library][webview].
> [!NOTE]
> Versions <= 0.1.1 are available in the [old repository][webview].
### Getting Started
See [Go package documentation][go-docs] for the Go API documentation, or simply read the source code.
Start with creating a new directory structure for your project.
```sh
mkdir my-project && cd my-project
```
Create a new Go module.
```sh
go mod init example.com/app
```
Save one of the example programs into your project directory.
```sh
curl -sSLo main.go "https://raw.githubusercontent.com/webview/webview_go/master/examples/basic/main.go"
```
Install dependencies.
```sh
go get github.com/webview/webview_go
```
Build the example. On Windows, add `-ldflags="-H windowsgui"` to the command line.
```sh
go build
```
### Notes
Calling `Eval()` or `Dispatch()` before `Run()` does not work because the webview instance has only been configured and not yet started.
[go-docs]: https://pkg.go.dev/github.com/webview/webview_go
[webview]: https://github.com/webview/webview
+12
View File
@@ -0,0 +1,12 @@
package main
import webview "github.com/webview/webview_go"
func main() {
w := webview.New(false)
defer w.Destroy()
w.SetTitle("Basic Example")
w.SetSize(480, 320, webview.HintNone)
w.SetHtml("Thanks for using webview!")
w.Run()
}
+38
View File
@@ -0,0 +1,38 @@
package main
import webview "github.com/webview/webview_go"
const html = `<button id="increment">Tap me</button>
<div>You tapped <span id="count">0</span> time(s).</div>
<script>
const [incrementElement, countElement] =
document.querySelectorAll("#increment, #count");
document.addEventListener("DOMContentLoaded", () => {
incrementElement.addEventListener("click", () => {
window.increment().then(result => {
countElement.textContent = result.count;
});
});
});
</script>`
type IncrementResult struct {
Count uint `json:"count"`
}
func main() {
var count uint = 0
w := webview.New(false)
defer w.Destroy()
w.SetTitle("Bind Example")
w.SetSize(480, 320, webview.HintNone)
// A binding that increments a value and immediately returns the new value.
w.Bind("increment", func() IncrementResult {
count++
return IncrementResult{Count: count}
})
w.SetHtml(html)
w.Run()
}
+36
View File
@@ -0,0 +1,36 @@
#include "webview.h"
#include <stdlib.h>
#include <stdint.h>
struct binding_context {
webview_t w;
uintptr_t index;
};
void _webviewDispatchGoCallback(void *);
void _webviewBindingGoCallback(webview_t, char *, char *, uintptr_t);
static void _webview_dispatch_cb(webview_t w, void *arg) {
_webviewDispatchGoCallback(arg);
}
static void _webview_binding_cb(const char *id, const char *req, void *arg) {
struct binding_context *ctx = (struct binding_context *) arg;
_webviewBindingGoCallback(ctx->w, (char *)id, (char *)req, ctx->index);
}
void CgoWebViewDispatch(webview_t w, uintptr_t arg) {
webview_dispatch(w, _webview_dispatch_cb, (void *)arg);
}
void CgoWebViewBind(webview_t w, const char *name, uintptr_t index) {
struct binding_context *ctx = calloc(1, sizeof(struct binding_context));
ctx->w = w;
ctx->index = index;
webview_bind(w, name, _webview_binding_cb, (void *)ctx);
}
void CgoWebViewUnbind(webview_t w, const char *name) {
webview_unbind(w, name);
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/webview/webview_go
go 1.13
View File
+27
View File
@@ -0,0 +1,27 @@
Copyright (C) Microsoft Corporation. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* The name of Microsoft Corporation, or the names of its contributors
may not be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
// Dummy file to allow vendoring.
package vendor
+2
View File
@@ -0,0 +1,2 @@
// Dummy file to allow vendoring.
package vendor
+1
View File
@@ -0,0 +1 @@
1.0.1150.38
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2017 Serge Zaitsev
Copyright (c) 2022 Steffen André Langnes
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.
+2
View File
@@ -0,0 +1,2 @@
// Dummy file to allow vendoring.
package vendor
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
// Dummy file to allow vendoring.
package vendor
+1
View File
@@ -0,0 +1 @@
fb6b17d826041411e6346cd9a785a5ceba7987c4
+1
View File
@@ -0,0 +1 @@
#include "webview.h"
+331
View File
@@ -0,0 +1,331 @@
package webview
/*
#cgo CFLAGS: -I${SRCDIR}/libs/webview/include
#cgo CXXFLAGS: -I${SRCDIR}/libs/webview/include -DWEBVIEW_STATIC
#cgo linux openbsd freebsd netbsd CXXFLAGS: -DWEBVIEW_GTK -std=c++11
#cgo linux openbsd freebsd netbsd LDFLAGS: -ldl
#cgo linux openbsd freebsd netbsd pkg-config: gtk+-3.0 webkit2gtk-4.0
#cgo darwin CXXFLAGS: -DWEBVIEW_COCOA -std=c++11
#cgo darwin LDFLAGS: -framework WebKit -ldl
#cgo windows CXXFLAGS: -DWEBVIEW_EDGE -std=c++14 -I${SRCDIR}/libs/mswebview2/include
#cgo windows LDFLAGS: -static -ladvapi32 -lole32 -lshell32 -lshlwapi -luser32 -lversion
#include "webview.h"
#include <stdlib.h>
#include <stdint.h>
void CgoWebViewDispatch(webview_t w, uintptr_t arg);
void CgoWebViewBind(webview_t w, const char *name, uintptr_t index);
void CgoWebViewUnbind(webview_t w, const char *name);
*/
import "C"
import (
_ "github.com/webview/webview_go/libs/mswebview2"
_ "github.com/webview/webview_go/libs/mswebview2/include"
_ "github.com/webview/webview_go/libs/webview"
_ "github.com/webview/webview_go/libs/webview/include"
"encoding/json"
"errors"
"reflect"
"runtime"
"sync"
"unsafe"
)
func init() {
// Ensure that main.main is called from the main thread
runtime.LockOSThread()
}
// Hints are used to configure window sizing and resizing
type Hint int
const (
// Width and height are default size
HintNone = C.WEBVIEW_HINT_NONE
// Window size can not be changed by a user
HintFixed = C.WEBVIEW_HINT_FIXED
// Width and height are minimum bounds
HintMin = C.WEBVIEW_HINT_MIN
// Width and height are maximum bounds
HintMax = C.WEBVIEW_HINT_MAX
)
type WebView interface {
// Run runs the main loop until it's terminated. After this function exits -
// you must destroy the webview.
Run()
// Terminate stops the main loop. It is safe to call this function from
// a background thread.
Terminate()
// Dispatch posts a function to be executed on the main thread. You normally
// do not need to call this function, unless you want to tweak the native
// window.
Dispatch(f func())
// Destroy destroys a webview and closes the native window.
Destroy()
// Window returns a native window handle pointer. When using GTK backend the
// pointer is GtkWindow pointer, when using Cocoa backend the pointer is
// NSWindow pointer, when using Win32 backend the pointer is HWND pointer.
Window() unsafe.Pointer
// SetTitle updates the title of the native window. Must be called from the UI
// thread.
SetTitle(title string)
// SetSize updates native window size. See Hint constants.
SetSize(w int, h int, hint Hint)
// Navigate navigates webview to the given URL. URL may be a properly encoded data.
// URI. Examples:
// w.Navigate("https://github.com/webview/webview")
// w.Navigate("data:text/html,%3Ch1%3EHello%3C%2Fh1%3E")
// w.Navigate("data:text/html;base64,PGgxPkhlbGxvPC9oMT4=")
Navigate(url string)
// SetHtml sets the webview HTML directly.
// Example: w.SetHtml(w, "<h1>Hello</h1>");
SetHtml(html string)
// Init injects JavaScript code at the initialization of the new page. Every
// time the webview will open a the new page - this initialization code will
// be executed. It is guaranteed that code is executed before window.onload.
Init(js string)
// Eval evaluates arbitrary JavaScript code. Evaluation happens asynchronously,
// also the result of the expression is ignored. Use RPC bindings if you want
// to receive notifications about the results of the evaluation.
Eval(js string)
// Bind binds a callback function so that it will appear under the given name
// as a global JavaScript function. Internally it uses webview_init().
// Callback receives a request string and a user-provided argument pointer.
// Request string is a JSON array of all the arguments passed to the
// JavaScript function.
//
// f must be a function
// f must return either value and error or just error
Bind(name string, f interface{}) error
// Removes a callback that was previously set by Bind.
Unbind(name string) error
}
type webview struct {
w C.webview_t
}
var (
m sync.Mutex
index uintptr
dispatch = map[uintptr]func(){}
bindings = map[uintptr]func(id, req string) (interface{}, error){}
)
func boolToInt(b bool) C.int {
if b {
return 1
}
return 0
}
// New calls NewWindow to create a new window and a new webview instance. If debug
// is non-zero - developer tools will be enabled (if the platform supports them).
func New(debug bool) WebView { return NewWindow(debug, nil) }
// NewWindow creates a new webview instance. If debug is non-zero - developer
// tools will be enabled (if the platform supports them). Window parameter can be
// a pointer to the native window handle. If it's non-null - then child WebView is
// embedded into the given parent window. Otherwise a new window is created.
// Depending on the platform, a GtkWindow, NSWindow or HWND pointer can be passed
// here.
func NewWindow(debug bool, window unsafe.Pointer) WebView {
w := &webview{}
w.w = C.webview_create(boolToInt(debug), window)
return w
}
func (w *webview) Destroy() {
C.webview_destroy(w.w)
}
func (w *webview) Run() {
C.webview_run(w.w)
}
func (w *webview) Terminate() {
C.webview_terminate(w.w)
}
func (w *webview) Window() unsafe.Pointer {
return C.webview_get_window(w.w)
}
func (w *webview) Navigate(url string) {
s := C.CString(url)
defer C.free(unsafe.Pointer(s))
C.webview_navigate(w.w, s)
}
func (w *webview) SetHtml(html string) {
s := C.CString(html)
defer C.free(unsafe.Pointer(s))
C.webview_set_html(w.w, s)
}
func (w *webview) SetTitle(title string) {
s := C.CString(title)
defer C.free(unsafe.Pointer(s))
C.webview_set_title(w.w, s)
}
func (w *webview) SetSize(width int, height int, hint Hint) {
C.webview_set_size(w.w, C.int(width), C.int(height), C.webview_hint_t(hint))
}
func (w *webview) Init(js string) {
s := C.CString(js)
defer C.free(unsafe.Pointer(s))
C.webview_init(w.w, s)
}
func (w *webview) Eval(js string) {
s := C.CString(js)
defer C.free(unsafe.Pointer(s))
C.webview_eval(w.w, s)
}
func (w *webview) Dispatch(f func()) {
m.Lock()
for ; dispatch[index] != nil; index++ {
}
dispatch[index] = f
m.Unlock()
C.CgoWebViewDispatch(w.w, C.uintptr_t(index))
}
//export _webviewDispatchGoCallback
func _webviewDispatchGoCallback(index unsafe.Pointer) {
m.Lock()
f := dispatch[uintptr(index)]
delete(dispatch, uintptr(index))
m.Unlock()
f()
}
//export _webviewBindingGoCallback
func _webviewBindingGoCallback(w C.webview_t, id *C.char, req *C.char, index uintptr) {
m.Lock()
f := bindings[uintptr(index)]
m.Unlock()
jsString := func(v interface{}) string { b, _ := json.Marshal(v); return string(b) }
status, result := 0, ""
if res, err := f(C.GoString(id), C.GoString(req)); err != nil {
status = -1
result = jsString(err.Error())
} else if b, err := json.Marshal(res); err != nil {
status = -1
result = jsString(err.Error())
} else {
status = 0
result = string(b)
}
s := C.CString(result)
defer C.free(unsafe.Pointer(s))
C.webview_return(w, id, C.int(status), s)
}
func (w *webview) Bind(name string, f interface{}) error {
v := reflect.ValueOf(f)
// f must be a function
if v.Kind() != reflect.Func {
return errors.New("only functions can be bound")
}
// f must return either value and error or just error
if n := v.Type().NumOut(); n > 2 {
return errors.New("function may only return a value or a value+error")
}
binding := func(id, req string) (interface{}, error) {
raw := []json.RawMessage{}
if err := json.Unmarshal([]byte(req), &raw); err != nil {
return nil, err
}
isVariadic := v.Type().IsVariadic()
numIn := v.Type().NumIn()
if (isVariadic && len(raw) < numIn-1) || (!isVariadic && len(raw) != numIn) {
return nil, errors.New("function arguments mismatch")
}
args := []reflect.Value{}
for i := range raw {
var arg reflect.Value
if isVariadic && i >= numIn-1 {
arg = reflect.New(v.Type().In(numIn - 1).Elem())
} else {
arg = reflect.New(v.Type().In(i))
}
if err := json.Unmarshal(raw[i], arg.Interface()); err != nil {
return nil, err
}
args = append(args, arg.Elem())
}
errorType := reflect.TypeOf((*error)(nil)).Elem()
res := v.Call(args)
switch len(res) {
case 0:
// No results from the function, just return nil
return nil, nil
case 1:
// One result may be a value, or an error
if res[0].Type().Implements(errorType) {
if res[0].Interface() != nil {
return nil, res[0].Interface().(error)
}
return nil, nil
}
return res[0].Interface(), nil
case 2:
// Two results: first one is value, second is error
if !res[1].Type().Implements(errorType) {
return nil, errors.New("second return value must be an error")
}
if res[1].Interface() == nil {
return res[0].Interface(), nil
}
return res[0].Interface(), res[1].Interface().(error)
default:
return nil, errors.New("unexpected number of return values")
}
}
m.Lock()
for ; bindings[index] != nil; index++ {
}
bindings[index] = binding
m.Unlock()
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
C.CgoWebViewBind(w.w, cname, C.uintptr_t(index))
return nil
}
func (w *webview) Unbind(name string) error {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
C.CgoWebViewUnbind(w.w, cname)
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package webview
import (
"flag"
"log"
"os"
"testing"
)
func Example() {
w := New(true)
defer w.Destroy()
w.SetTitle("Hello")
w.Bind("noop", func() string {
log.Println("hello")
return "hello"
})
w.Bind("add", func(a, b int) int {
return a + b
})
w.Bind("quit", func() {
w.Terminate()
})
w.SetHtml(`<!doctype html>
<html>
<body>hello</body>
<script>
window.onload = function() {
document.body.innerText = ` + "`hello, ${navigator.userAgent}`" + `;
noop().then(function(res) {
console.log('noop res', res);
add(1, 2).then(function(res) {
console.log('add res', res);
quit();
});
});
};
</script>
</html>
)`)
w.Run()
}
func TestMain(m *testing.M) {
flag.Parse()
if testing.Verbose() {
Example()
}
os.Exit(m.Run())
}
+3 -1
View File
@@ -5,9 +5,9 @@ go 1.23.1
replace github.com/pajlada/go-twitch-pubsub => github.com/plugnburn/go-twitch-pubsub v0.0.0-20240922200010-4bf88de12fae replace github.com/pajlada/go-twitch-pubsub => github.com/plugnburn/go-twitch-pubsub v0.0.0-20240922200010-4bf88de12fae
require ( require (
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16
github.com/DaRealFreak/cloudflare-bp-go v1.0.4 github.com/DaRealFreak/cloudflare-bp-go v1.0.4
github.com/plugnburn/go-twitch-pubsub v0.0.0-20240922200010-4bf88de12fae github.com/plugnburn/go-twitch-pubsub v0.0.0-20240922200010-4bf88de12fae
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
) )
require ( require (
@@ -24,3 +24,5 @@ require (
golang.org/x/tools v0.22.0 // indirect golang.org/x/tools v0.22.0 // indirect
honnef.co/go/tools v0.4.7 // indirect honnef.co/go/tools v0.4.7 // indirect
) )
replace github.com/webview/webview_go => ../external/webview_go
-2
View File
@@ -1,5 +1,3 @@
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16 h1:gOFnYZ+G9R7Prc3Mn7z/v+LkPf/kQ2oUh5NCQd6n39k=
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16/go.mod h1:cAr/pT090G1kcy9uuCEUbZyGhUQRGOo4KVnkocx8BMA=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DaRealFreak/cloudflare-bp-go v1.0.4 h1:33X8Z0YMV1DEVvL/kYLku+rjb4wF712+VIh3xBoifQ0= github.com/DaRealFreak/cloudflare-bp-go v1.0.4 h1:33X8Z0YMV1DEVvL/kYLku+rjb4wF712+VIh3xBoifQ0=
+53
View File
@@ -0,0 +1,53 @@
//go:build gui && linux
/*
StreamGoose UI — Go reference implementation for Linux (GTK4 + WebKitGTK 6.0)
Created by Luxferre in 2024, updated in 2026, released into public domain
*/
package main
/*
#cgo pkg-config: gtk4 webkitgtk-6.0
#include <gtk/gtk.h>
#include <webkit/webkit.h>
#include <stdlib.h>
static void activate(GtkApplication *app, gpointer user_data) {
const char *url = (const char *)user_data;
GtkWidget *window = gtk_application_window_new(app);
gtk_window_set_title(GTK_WINDOW(window), "StreamGoose");
gtk_window_set_default_size(GTK_WINDOW(window), 320, 720);
GtkWidget *webview = webkit_web_view_new();
webkit_web_view_load_uri(WEBKIT_WEB_VIEW(webview), url);
gtk_window_set_child(GTK_WINDOW(window), webview);
gtk_window_present(GTK_WINDOW(window));
}
static void run_webview(const char *url) {
GtkApplication *app = gtk_application_new("org.luxferre.streamgoose", G_APPLICATION_DEFAULT_FLAGS);
g_signal_connect(app, "activate", G_CALLBACK(activate), (gpointer)url);
g_application_run(G_APPLICATION(app), 0, NULL);
g_object_unref(app);
}
*/
import "C"
import (
"fmt"
"unsafe"
)
/* override GUI entry point */
func init() {
goose_start_gui = func(config map[string]interface{}) {
/* target server listening address */
target_addr := fmt.Sprintf("%s:%d", config["server_listen_ip"].(string), int(config["server_port"].(float64)))
url := "http://" + target_addr
curl := C.CString(url)
defer C.free(unsafe.Pointer(curl))
C.run_webview(curl)
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
//go:build gui //go:build gui && !linux
/* /*
StreamGoose UI Go reference implementation StreamGoose UI Go reference implementation
@@ -8,7 +8,7 @@ package main
import ( import (
"fmt" "fmt"
"centrifuge.hectabit.org/HectaBit/webview_go" /* for UI launch */ "github.com/webview/webview_go" /* for UI launch */
) )
/* override GUI entry point */ /* override GUI entry point */
+1 -1
View File
@@ -109,7 +109,7 @@ func kk_process_message(rawmsg map[string]interface{}) {
out_message := ChatMessage { out_message := ChatMessage {
Type: "kk", Type: "kk",
NativeId: rawmsg["id"].(string), NativeId: rawmsg["id"].(string),
AuthorId: string(int64(author["id"].(float64))), AuthorId: fmt.Sprintf("%d", int64(author["id"].(float64))),
Username: author["username"].(string), Username: author["username"].(string),
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: rawmsg["content"].(string), Text: rawmsg["content"].(string),
+114 -25
View File
@@ -19,10 +19,13 @@ import (
"path/filepath" "path/filepath"
"unicode/utf8" "unicode/utf8"
"encoding/json" "encoding/json"
"bufio"
"sync"
) )
/* Twitch badge URL cache */ /* Twitch badge URL cache */
var TW_BADGE_CACHE map[string]string var TW_BADGE_CACHE map[string]string
var TW_USER_ICON_CACHE sync.Map
/* Twitch OAuth global cache */ /* Twitch OAuth global cache */
var TW_OAUTH_TOKEN string var TW_OAUTH_TOKEN string
var TW_CLIENT_ID string var TW_CLIENT_ID string
@@ -70,11 +73,36 @@ func tw_api_req(url string, data string) []byte {
return body return body
} }
/* User icon fetching and caching function */
func tw_get_user_icon(username string) string {
if icon, ok := TW_USER_ICON_CACHE.Load(username); ok {
return icon.(string)
}
/* Fallback to decapi.me due to Helix API 401 Token Scoping limitations on generic twitchapps auths */
resp, err := http.Get("https://decapi.me/twitch/avatar/" + username)
if err == nil {
body, err := ioutil.ReadAll(resp.Body)
if err == nil {
url := strings.TrimSpace(string(body))
if strings.HasPrefix(url, "https://") {
TW_USER_ICON_CACHE.Store(username, url)
return url
}
}
resp.Body.Close()
}
TW_USER_ICON_CACHE.Store(username, "")
return ""
}
/* badge cache population function */ /* badge cache population function */
func tw_fetch_badge_cache(channelname string) map[string]string { func tw_fetch_badge_cache(channelname string) map[string]string {
readycache := make(map[string]string) readycache := make(map[string]string)
var ustruct map[string]interface{} var ustruct map[string]interface{}
/* get broadcaster ID from the channel name */
/* get broadcaster ID from the channel name via Helix API */
body := tw_api_req("/users?login=" + channelname, "") body := tw_api_req("/users?login=" + channelname, "")
if len(body) > 0 { if len(body) > 0 {
err := json.Unmarshal(body, &ustruct) err := json.Unmarshal(body, &ustruct)
@@ -85,6 +113,7 @@ func tw_fetch_badge_cache(channelname string) map[string]string {
} }
} }
} }
/* get the global badges */ /* get the global badges */
body = tw_api_req("/chat/badges/global", "") body = tw_api_req("/chat/badges/global", "")
if len(body) > 0 { if len(body) > 0 {
@@ -102,6 +131,7 @@ func tw_fetch_badge_cache(channelname string) map[string]string {
} }
} }
} }
if TW_BROADCASTER_ID != "" { /* also get the channel-specific badges */ if TW_BROADCASTER_ID != "" { /* also get the channel-specific badges */
body = tw_api_req(fmt.Sprintf("/chat/badges?broadcaster_id=%s", TW_BROADCASTER_ID), "") body = tw_api_req(fmt.Sprintf("/chat/badges?broadcaster_id=%s", TW_BROADCASTER_ID), "")
if len(body) > 0 { if len(body) > 0 {
@@ -153,49 +183,63 @@ func tw_handle_emotes(text string, emote_data string) string {
/* main message procesing function */ /* main message procesing function */
func tw_process_message(rawmsg string) { func tw_process_message(rawmsg string) {
if !strings.HasPrefix(rawmsg, "@") {
return
}
parts := strings.Split(rawmsg, " :") /* get raw message parts */ parts := strings.Split(rawmsg, " :") /* get raw message parts */
/* first part is metadata, second is a full IRC username, /* first part is metadata, second is a full IRC sender+command, the rest is message */
the rest is the message ending with \r\n */ metadata_str := strings.TrimSpace(parts[0][1:]) // remove the leading '@'
metadata_str := strings.TrimSpace(parts[0])
msg_text := "" msg_text := ""
if(len(parts) > 2) { if(len(parts) > 2) {
msg_text = strings.Join(parts[2:], " :") msg_text = strings.Join(parts[2:], " :")
msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */ msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */
} }
command := ""
if len(parts) > 1 {
subparts := strings.Fields(parts[1])
if len(subparts) > 1 {
command = subparts[1]
}
}
metadata := make(map[string]string) /* store parsed metadata here */ metadata := make(map[string]string) /* store parsed metadata here */
parts = strings.Split(metadata_str, ";") mparts := strings.Split(metadata_str, ";")
for _, field := range parts { /* iterate over metadata fields */ for _, field := range mparts { /* iterate over metadata fields */
if strings.Contains(field, "=") { if strings.Contains(field, "=") {
fieldkey, fieldval, _ := strings.Cut(field, "=") fieldkey, fieldval, _ := strings.Cut(field, "=")
metadata[fieldkey] = fieldval metadata[fieldkey] = fieldval
} }
} }
/* if the message came from a real user, this is the one we should process */
/* but first, check if this is a single-message deletion event */ if command == "CLEARMSG" {
if strings.HasPrefix(rawmsg, "@login=") && strings.Contains(rawmsg, " CLEARMSG ") {
/* prepare output message */ /* prepare output message */
out_message := ChatMessage { out_message := ChatMessage {
Type: "delmsg", Type: "delmsg",
NativeId: metadata["target-msg-id"], NativeId: metadata["target-msg-id"],
} }
/* send it to the message broker via the message_transport channel */
message_transport <- out_message message_transport <- out_message
} else if strings.Contains(rawmsg, " CLEARCHAT ") { /* bulk message deletion event */ } else if command == "CLEARCHAT" {
/* prepare output message */
out_message := ChatMessage { out_message := ChatMessage {
Type: "deluser", Type: "deluser",
AuthorId: metadata["target-user-id"], AuthorId: metadata["target-user-id"],
} }
/* send it to the message broker via the message_transport channel */
message_transport <- out_message message_transport <- out_message
} else if strings.HasPrefix(rawmsg, "@badge-info=") { /* normal message or usernotice */ } else if command == "PRIVMSG" || command == "USERNOTICE" {
/* normal message or usernotice */
/* Check if it's a valid message by confirming we have a message ID */
if metadata["id"] == "" {
return
}
/* convert the timestamp */ /* convert the timestamp */
ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0) ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0)
if err != nil { /* fallback to current time if anything */ if err != nil { /* fallback to current time if anything */
ts = time.Now().UnixMilli() ts = time.Now().UnixMilli()
} }
sent_dt := time.UnixMilli(ts) sent_dt := time.UnixMilli(ts)
/* process Twitch emotes before any positional changes can occur */ /* process Twitch emotes before any positional changes can occur */
if _, ok := metadata["emotes"]; ok && len(metadata["emotes"]) > 0 { if _, ok := metadata["emotes"]; ok && len(metadata["emotes"]) > 0 {
msg_text = tw_handle_emotes(msg_text, metadata["emotes"]) msg_text = tw_handle_emotes(msg_text, metadata["emotes"])
@@ -215,6 +259,19 @@ func tw_process_message(rawmsg string) {
Text: strings.TrimSpace(msg_text), Text: strings.TrimSpace(msg_text),
Icon: "", Icon: "",
} }
// Fallback if display-name is empty
if out_message.Username == "" {
if len(parts) > 1 {
headerParts := strings.Split(parts[1], " ")
if len(headerParts) > 0 {
senderSplits := strings.Split(headerParts[0], "!")
if len(senderSplits) > 0 {
out_message.Username = senderSplits[0]
}
}
}
}
/* process username badges */ /* process username badges */
if _, ok := metadata["badges"]; ok { if _, ok := metadata["badges"]; ok {
@@ -250,8 +307,21 @@ func tw_process_message(rawmsg string) {
} }
} }
/* finally, send it to the message broker via the message_transport channel */ /* fetch user icons asynchronously so we don't hold up IRC parser speed */
message_transport <- out_message if out_message.Username != "" {
if icon, ok := TW_USER_ICON_CACHE.Load(out_message.Username); ok {
out_message.Icon = icon.(string)
message_transport <- out_message
} else {
go func(msg ChatMessage) {
msg.Icon = tw_get_user_icon(msg.Username)
message_transport <- msg
}(out_message)
}
} else {
/* finally, send it to the message broker via the message_transport channel */
message_transport <- out_message
}
} }
} }
@@ -282,6 +352,21 @@ func twitch_run(config map[string]interface{}) {
/* populate TW_OAUTH_TOKEN and TW_CLIENT_ID globals for non-IRC API calls */ /* populate TW_OAUTH_TOKEN and TW_CLIENT_ID globals for non-IRC API calls */
TW_CLIENT_ID = strings.TrimSpace(string(cid)) TW_CLIENT_ID = strings.TrimSpace(string(cid))
TW_OAUTH_TOKEN, _ = strings.CutPrefix(config["tw_token"].(string), "oauth:") TW_OAUTH_TOKEN, _ = strings.CutPrefix(config["tw_token"].(string), "oauth:")
/* Automatically detect the correct Client-ID for the OAuth token */
req, _ := http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil)
req.Header.Set("Authorization", "OAuth " + TW_OAUTH_TOKEN)
client := &http.Client{}
if resp, err := client.Do(req); err == nil {
body, _ := ioutil.ReadAll(resp.Body)
var vstruct map[string]interface{}
if err := json.Unmarshal(body, &vstruct); err == nil {
if autoCid, ok := vstruct["client_id"]; ok {
TW_CLIENT_ID = autoCid.(string)
}
}
resp.Body.Close()
}
/* load the Twitch badge cache */ /* load the Twitch badge cache */
chname, _ := strings.CutPrefix(config["tw_channel"].(string), "#") chname, _ := strings.CutPrefix(config["tw_channel"].(string), "#")
@@ -294,26 +379,30 @@ func twitch_run(config map[string]interface{}) {
log.Fatal("[twitch] Error connecting to Twitch: ", err) log.Fatal("[twitch] Error connecting to Twitch: ", err)
} }
tcpwritestr(conn, fmt.Sprintf("PASS %s\n", config["tw_token"])) /* request advanced chat message capabilities BEFORE login/join */
tcpwritestr(conn, fmt.Sprintf("NICK %s\n", config["tw_nickname"])) tcpwritestr(conn, "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands\r\n")
tcpwritestr(conn, fmt.Sprintf("JOIN %s\n", config["tw_channel"])) tcpwritestr(conn, fmt.Sprintf("PASS %s\r\n", config["tw_token"]))
/* request advanced chat message capabilities */ tcpwritestr(conn, fmt.Sprintf("NICK %s\r\n", strings.ToLower(config["tw_nickname"].(string))))
tcpwritestr(conn, "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands\n") tcpwritestr(conn, fmt.Sprintf("JOIN %s\r\n", config["tw_channel"]))
/* set up subscriptions and start the WebSocket event loop goroutine */ /* set up subscriptions and start the WebSocket event loop goroutine */
go tw_ws_pubsub_readloop() go tw_ws_pubsub_readloop()
log.Println("StreamGoose Twitch Go backend connected") log.Println("StreamGoose Twitch Go backend connected")
var rawmsg string reader := bufio.NewReader(conn)
for { /* main loop */ for { /* main loop */
rawmsg = tcpreadstr(conn) rawmsg, err := reader.ReadString('\n')
if err != nil {
log.Fatal("[twitch] Error reading from socket: ", err)
}
rawmsg = strings.TrimRight(rawmsg, "\r\n") // remove CRLF
if _, ok := config["debug"]; ok && config["debug"].(bool) == true { if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
log.Println(rawmsg) log.Println(rawmsg)
} }
if strings.HasPrefix(rawmsg, "PING") { /* respond to the PING message */ if strings.HasPrefix(rawmsg, "PING") { /* respond to the PING message */
tcpwritestr(conn, "PONG\n") tcpwritestr(conn, "PONG :tmi.twitch.tv\r\n")
} else { /* run main procesing */ } else { /* run main procesing */
tw_process_message(rawmsg) tw_process_message(rawmsg)
} }
+129 -26
View File
@@ -2,6 +2,7 @@
StreamGoose YouTube backend — Innertube API StreamGoose YouTube backend — Innertube API
Created by Luxferre in 2024, released into public domain Created by Luxferre in 2024, released into public domain
Updated in 2026 to implement more robust logic
*/ */
package main package main
@@ -27,6 +28,7 @@ type YTIChatOptions struct {
Continuation string `json:"continuation"` /* continuation key */ Continuation string `json:"continuation"` /* continuation key */
ClientName string `json:"clientName"` /* client name string */ ClientName string `json:"clientName"` /* client name string */
ClientVersion string `json:"clientVersion"` /* client version string */ ClientVersion string `json:"clientVersion"` /* client version string */
VisitorData string `json:"visitorData"` /* user tracking ID */
} }
/* API request helper function */ /* API request helper function */
@@ -36,8 +38,15 @@ func yt_inner_api_req(url string, data []byte) string {
method = "POST" method = "POST"
} }
req, err := http.NewRequest(method, url, bytes.NewReader(data)) req, err := http.NewRequest(method, url, bytes.NewReader(data))
if err != nil {
log.Fatal(err)
}
req.Header.Set("User-Agent", YT_INNER_USERAGENT) req.Header.Set("User-Agent", YT_INNER_USERAGENT)
req.Header.Set("Accept-Language", "en-US") req.Header.Set("Accept-Language", "en-US")
if len(data) > 0 {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://www.youtube.com")
}
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
@@ -60,12 +69,20 @@ func yt_inner_runs_to_text(runs []interface{}) string {
run := rrun.(map[string]interface{}) run := rrun.(map[string]interface{})
if _, ok := run["text"]; ok { /* normal text */ if _, ok := run["text"]; ok { /* normal text */
out_text += run["text"].(string) out_text += run["text"].(string)
} else if _, ok := run["emoji"]; ok { /* this is where the fun begins */ } else if _, ok := run["emoji"]; ok { /* emoji handling */
emoji := run["emoji"].(map[string]interface{}) emoji := run["emoji"].(map[string]interface{})
if _, ok := emoji["isCustomEmoji"]; ok && emoji["isCustomEmoji"].(bool) == true { /* custom emoji */ if isCustom, ok := emoji["isCustomEmoji"]; ok && isCustom.(bool) == true { /* custom emoji */
/* TODO: out_text += " #YTEMOTE-" + emoji["image"]["thumbnails"][0]["url"] + "# " */ /* dynamically fetch emoji thumbnail */
if len(emoji["shortcuts"].([]interface{})) > 0 { if image, imgOk := emoji["image"].(map[string]interface{}); imgOk {
out_text += " " + emoji["shortcuts"].([]interface{})[0].(string) + " " if thumbnails, thOk := image["thumbnails"].([]interface{}); thOk && len(thumbnails) > 0 {
thumb := thumbnails[0].(map[string]interface{})
out_text += fmt.Sprintf(" <img src=\"%s\" class=\"yt-emote\" /> ", thumb["url"].(string))
}
} else {
/* fallback to text shortcut */
if shortcuts, scOk := emoji["shortcuts"].([]interface{}); scOk && len(shortcuts) > 0 {
out_text += " " + shortcuts[0].(string) + " "
}
} }
} else { /* standard Unicode emoji, pass as text */ } else { /* standard Unicode emoji, pass as text */
out_text += emoji["emojiId"].(string) out_text += emoji["emojiId"].(string)
@@ -77,6 +94,30 @@ func yt_inner_runs_to_text(runs []interface{}) string {
/* convert inner chat action into a public chat item */ /* convert inner chat action into a public chat item */
func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage { func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
/* Handle Message Deletions */
if _, ok := action["markChatItemAsDeletedAction"]; ok {
delAction := action["markChatItemAsDeletedAction"].(map[string]interface{})
targetId := delAction["targetItemId"].(string)
return ChatMessage{
Type: "yt^delmsg",
NativeId: targetId,
}
}
if _, ok := action["markChatItemsByAuthorAsDeletedAction"]; ok {
delAction := action["markChatItemsByAuthorAsDeletedAction"].(map[string]interface{})
authorId := delAction["deletedChannelId"].(string)
return ChatMessage{
Type: "yt^deluser",
AuthorId: authorId,
}
}
/* Handle Normal Additions */
if _, ok := action["addChatItemAction"]; !ok {
return ChatMessage{}
}
item := action["addChatItemAction"].(map[string]interface{})["item"].(map[string]interface{}) item := action["addChatItemAction"].(map[string]interface{})["item"].(map[string]interface{})
/* get the renderer */ /* get the renderer */
var renderer map[string]interface{} var renderer map[string]interface{}
@@ -91,9 +132,11 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
} else { } else {
renderer = nil renderer = nil
} }
if renderer == nil { /* invalid renderer field */ if renderer == nil { /* invalid renderer field */
return ChatMessage{} return ChatMessage{}
} }
/* here, message text is composed as a series of individual runs */ /* here, message text is composed as a series of individual runs */
var mruns []interface{} var mruns []interface{}
if _, ok := renderer["message"]; ok { if _, ok := renderer["message"]; ok {
@@ -114,6 +157,7 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
author_icon = thumbnails[0].(map[string]interface{})["url"].(string) author_icon = thumbnails[0].(map[string]interface{})["url"].(string)
} }
} }
/* fetch the timestamp (microseconds) */ /* fetch the timestamp (microseconds) */
ts, err := strconv.ParseInt(renderer["timestampUsec"].(string), 10, 0) ts, err := strconv.ParseInt(renderer["timestampUsec"].(string), 10, 0)
if err != nil { /* fallback to current time if anything */ if err != nil { /* fallback to current time if anything */
@@ -125,13 +169,17 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
out_message := ChatMessage { out_message := ChatMessage {
Type: "yt", Type: "yt",
NativeId: renderer["id"].(string), NativeId: renderer["id"].(string),
AuthorId: renderer["authorExternalChannelId"].(string),
Username: author_name, Username: author_name,
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: yt_inner_runs_to_text(mruns), Text: yt_inner_runs_to_text(mruns),
Icon: author_icon, Icon: author_icon,
} }
/* Optional field due to structure */
if _, ok := renderer["authorExternalChannelId"]; ok {
out_message.AuthorId = renderer["authorExternalChannelId"].(string)
}
/* check roles */ /* check roles */
if _, ok := renderer["authorBadges"]; ok { if _, ok := renderer["authorBadges"]; ok {
for _, entry := range renderer["authorBadges"].([]interface{}) { for _, entry := range renderer["authorBadges"].([]interface{}) {
@@ -151,6 +199,7 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
} }
} }
} }
if _, ok := renderer["purchaseAmountText"]; ok { if _, ok := renderer["purchaseAmountText"]; ok {
out_message.Type += "^sponsor" out_message.Type += "^sponsor"
} }
@@ -169,67 +218,121 @@ func yt_inner_get_chat_options(video_id string) YTIChatOptions {
} }
raw_data := yt_inner_api_req(live_url, nil) raw_data := yt_inner_api_req(live_url, nil)
opts.ClientName = "WEB" opts.ClientName = "WEB"
r, _ := regexp.Compile("\"INNERTUBE_API_KEY\":\"(.+?)\"")
r, _ := regexp.Compile("\"INNERTUBE_API_KEY\":\"([^\"]+)\"")
rmatch := r.FindStringSubmatch(raw_data) rmatch := r.FindStringSubmatch(raw_data)
if len(rmatch) > 0 { if len(rmatch) > 0 {
opts.Key = rmatch[1] opts.Key = rmatch[1]
} else { } else {
log.Fatal("[youtube] Cannot connect to the YouTube livechat (innertube key)") log.Fatal("[youtube] Cannot connect to the YouTube livechat (innertube key)")
} }
r, _ = regexp.Compile("\"continuation\":\"(.+?)\"")
r, _ = regexp.Compile("\"continuation\":\"([^\"]+)\"")
rmatch = r.FindStringSubmatch(raw_data) rmatch = r.FindStringSubmatch(raw_data)
if len(rmatch) > 0 { if len(rmatch) > 0 {
opts.Continuation = rmatch[1] opts.Continuation = rmatch[1]
} else { } else {
log.Fatal("[youtube] Cannot connect to the YouTube livechat (continuation)") log.Fatal("[youtube] Cannot connect to the YouTube livechat (continuation)")
} }
r, _ = regexp.Compile("\"clientVersion\":\"(.+?)\"")
r, _ = regexp.Compile("\"clientVersion\":\"([^\"]+)\"")
rmatch = r.FindStringSubmatch(raw_data) rmatch = r.FindStringSubmatch(raw_data)
if len(rmatch) > 0 { if len(rmatch) > 0 {
opts.ClientVersion = rmatch[1] opts.ClientVersion = rmatch[1]
} else { } else {
log.Fatal("[youtube] Cannot connect to the YouTube livechat (clientversion)") opts.ClientVersion = "2.20260206.01.00" // Fallback to a known youtubei.js version
} }
r, _ = regexp.Compile("\"visitorData\":\"([^\"]+)\"")
rmatch = r.FindStringSubmatch(raw_data)
if len(rmatch) > 0 {
opts.VisitorData = rmatch[1]
}
return opts return opts
} }
/* recursive function to find the next continuation token in the payload */
func find_continuation(node interface{}) string {
switch n := node.(type) {
case map[string]interface{}:
if cont, ok := n["continuation"]; ok {
if str, isStr := cont.(string); isStr {
return str
}
}
for _, v := range n {
if res := find_continuation(v); res != "" {
return res
}
}
case []interface{}:
for _, v := range n {
if res := find_continuation(v); res != "" {
return res
}
}
}
return ""
}
/* fetch chat data using the chat options */ /* fetch chat data using the chat options */
/* return the list of ChatMessage items */ /* return the list of ChatMessage items */
/* should update the options structure */ /* updates the options structure with the next continuation */
func yt_inner_get_chat_data(opts *YTIChatOptions) []ChatMessage { func yt_inner_get_chat_data(opts *YTIChatOptions) []ChatMessage {
jsondata := make(map[string]interface{}) jsondata := make(map[string]interface{})
jsondata["context"] = make(map[string]interface{}) jsondata["context"] = make(map[string]interface{})
jsondata["continuation"] = opts.Continuation jsondata["continuation"] = opts.Continuation
jsondata["context"].(map[string]interface{})["client"] = make(map[string]string)
jsondata["context"].(map[string]interface{})["client"].(map[string]string)["userAgent"] = YT_INNER_USERAGENT clientMap := make(map[string]interface{})
jsondata["context"].(map[string]interface{})["client"].(map[string]string)["clientName"] = opts.ClientName clientMap["hl"] = "en"
jsondata["context"].(map[string]interface{})["client"].(map[string]string)["clientVersion"] = opts.ClientVersion clientMap["gl"] = "US"
clientMap["userAgent"] = YT_INNER_USERAGENT
clientMap["clientName"] = opts.ClientName
clientMap["clientVersion"] = opts.ClientVersion
clientMap["timeZone"] = "UTC"
if opts.VisitorData != "" {
clientMap["visitorData"] = opts.VisitorData
}
jsondata["context"].(map[string]interface{})["client"] = clientMap
jsonstr, _ := json.Marshal(&jsondata) jsonstr, _ := json.Marshal(&jsondata)
/* post the marshalled string and get the result into chatdata */ /* post the marshalled string and get the result into chatdata */
target_url := fmt.Sprintf("https://www.youtube.com/youtubei/v1/live_chat/get_live_chat?key=%s", opts.Key) target_url := fmt.Sprintf("https://www.youtube.com/youtubei/v1/live_chat/get_live_chat?key=%s", opts.Key)
rawresp := yt_inner_api_req(target_url, jsonstr) rawresp := yt_inner_api_req(target_url, jsonstr)
var chatdata map[string]interface{} var chatdata map[string]interface{}
err := json.Unmarshal([]byte(rawresp), &chatdata) err := json.Unmarshal([]byte(rawresp), &chatdata)
if err != nil { if err != nil {
log.Fatal(err) /* Don't panic here on unmarshal errors, might be a temporary hiccup */
log.Println("[youtube] error decoding JSON continuation:", err)
return []ChatMessage{}
} }
var chatitems []ChatMessage var chatitems []ChatMessage
/* process the upper structure */
/* Look for the next continuation token aggressively */
if cd, ok := chatdata["continuationContents"]; ok {
nextCont := find_continuation(cd)
if nextCont != "" {
opts.Continuation = nextCont
} else {
log.Println("[youtube] warning: could not find next continuation token")
}
}
/* process the upper structure for actions */
if _, ok := chatdata["continuationContents"]; ok { if _, ok := chatdata["continuationContents"]; ok {
cc := chatdata["continuationContents"].(map[string]interface{}) cc := chatdata["continuationContents"].(map[string]interface{})
if _, ok := cc["liveChatContinuation"]; ok { if _, ok := cc["liveChatContinuation"]; ok {
lcc := cc["liveChatContinuation"].(map[string]interface{}) lcc := cc["liveChatContinuation"].(map[string]interface{})
if lcc["actions"] != nil { if lcc["actions"] != nil {
actions := lcc["actions"].([]interface{}) actions := lcc["actions"].([]interface{})
conts := lcc["continuations"].([]interface{})
icd := conts[0].(map[string]interface{})["invalidationContinuationData"].(map[string]interface{})
opts.Continuation = icd["continuation"].(string)
for _, action := range actions { /* iterate through the actions */ for _, action := range actions { /* iterate through the actions */
if _, ok := action.(map[string]interface{})["addChatItemAction"]; ok { chatitem := yt_inner_action_to_chatitem(action.(map[string]interface{}))
chatitem := yt_inner_action_to_chatitem(action.(map[string]interface{})) if chatitem.Type != "" {
if chatitem.Type != "" { chatitems = append(chatitems, chatitem)
chatitems = append(chatitems, chatitem)
}
} }
} }
} }
+28 -28
View File
@@ -2,8 +2,8 @@
/* escape HTML entities (for non-server-provided stuff) */ /* escape HTML entities (for non-server-provided stuff) */
function esc(s) { function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&apos;') .replace(/"/g, '&quot;').replace(/'/g, '&apos;')
} }
/* handle Twitch, YouTube and Kick emotes */ /* handle Twitch, YouTube and Kick emotes */
@@ -12,13 +12,13 @@ function handleEmotes(text) {
var kickurl = 'https://files.kick.com/emotes/$id/fullsize' var kickurl = 'https://files.kick.com/emotes/$id/fullsize'
/* Handle Twitch emotes coming from backends */ /* Handle Twitch emotes coming from backends */
text = text.replace(/#TWEMOTE-[^\s]+#/g, text = text.replace(/#TWEMOTE-[^\s]+#/g,
a=>'<img src="' + twurl.replace('$id', a.split('-')[1].slice(0,-1)) + '">') a => '<img src="' + twurl.replace('$id', a.split('-')[1].slice(0, -1)) + '">')
/* Handle Kick emotes coming from backends */ /* Handle Kick emotes coming from backends */
text = text.replace(/#KKEMOTE-[^\s]+#/g, text = text.replace(/#KKEMOTE-[^\s]+#/g,
a=>'<img src="' + kickurl.replace('$id', a.split('-')[1].slice(0,-1)) + '">') a => '<img src="' + kickurl.replace('$id', a.split('-')[1].slice(0, -1)) + '">')
/* Handle YouTube emotes */ /* Handle YouTube emotes */
text = text.replace(/:[^\s]+:/g, a => { text = text.replace(/:[^\s]+:/g, a => {
if(YT_EMOTES.indexOf(a) > -1) /* found in the database */ if (YT_EMOTES.indexOf(a) > -1) /* found in the database */
return '<img src="/img/yt-emotes/' + a + '.png">' return '<img src="/img/yt-emotes/' + a + '.png">'
else return a else return a
}) })
@@ -28,67 +28,67 @@ function handleEmotes(text) {
/* detect if there are any overridden styles */ /* detect if there are any overridden styles */
function overrideStyles(text) { function overrideStyles(text) {
var styles = '', re var styles = '', re
if(messageHighlightRules) { if (messageHighlightRules) {
for(re in messageHighlightRules) { for (re in messageHighlightRules) {
if((new RegExp(re, 'iu')).test(text)) if ((new RegExp(re, 'iu')).test(text))
styles += 'color:' + messageHighlightRules[re] + ';' styles += 'color:' + messageHighlightRules[re] + ';'
} }
} }
if(styles.length > 0) styles = ' style="' + esc(styles) + '"' if (styles.length > 0) styles = ' style="' + esc(styles) + '"'
return styles return styles
} }
/* individual message appender */ /* individual message appender */
function gooseAppendMessage(id, native_id, author_id, type, timestamp, username, text, icon) { function gooseAppendMessage(id, native_id, author_id, type, timestamp, username, text, icon) {
var html = "" var html = ""
if(parseInt(id) > 0 && type.length > 0) { if (parseInt(id) > 0 && type.length > 0) {
if(type == "delmsg") { /* message deletion event */ if (type == "delmsg") { /* message deletion event */
var el = document.querySelector('[data-native-id="' + esc(native_id) + '"]') var el = document.querySelector('[data-native-id="' + esc(native_id) + '"]')
if(el) el.parentNode.removeChild(el) if (el) el.parentNode.removeChild(el)
} else if(type == "deluser") { /* bulk deletion event by username */ } else if (type == "deluser") { /* bulk deletion event by username */
var els = document.querySelectorAll('[data-author-id="' + esc(author_id) + '"]') var els = document.querySelectorAll('[data-author-id="' + esc(author_id) + '"]')
if(els && els.length > 0) { if (els && els.length > 0) {
var i, l = els.length var i, l = els.length
for(i=0;i<l;i++) els[i].parentNode.removeChild(els[i]) for (i = 0; i < l; i++) els[i].parentNode.removeChild(els[i])
} }
} else { /* message appending event */ } else { /* message appending event */
var classes = type.split('^'), cl = classes.length, j, var classes = type.split('^'), cl = classes.length, j,
colorpref = null, badges = [], platform, bname colorpref = null, badges = [], platform, bname
for(j=0;j<cl;j++) { /* detect color preference */ for (j = 0; j < cl; j++) { /* detect color preference */
if(classes[j].startsWith('color')) if (classes[j].startsWith('color'))
colorpref = classes[j].slice(5) /* remove color word */ colorpref = classes[j].slice(5) /* remove color word */
if(classes[j].startsWith('badge')) { /* remove the class but cache it */ if (classes[j].startsWith('badge')) { /* remove the class but cache it */
badges.push(classes[j].slice(5)) /* remove badge word */ badges.push(classes[j].slice(5)) /* remove badge word */
classes[j] = "" /* remove the class from the list */ classes[j] = "" /* remove the class from the list */
} }
} }
html += '<div data-msg-id="' + id + '" data-native-id="' + esc(native_id) + '" data-author-id="' html += '<div data-msg-id="' + id + '" data-native-id="' + esc(native_id) + '" data-author-id="'
+ esc(author_id) + '" class="' + classes.join(' ') +'">' + esc(author_id) + '" class="' + classes.join(' ') + '">'
/* handle badges and icons */ /* handle badges and icons */
html += '<span class=icon>' html += '<span class=icon>'
for(j=0,cl=badges.length;j<cl;j++) { /* iterate over badges */ for (j = 0, cl = badges.length; j < cl; j++) { /* iterate over badges */
platform = badges[j].slice(0,2) /* first 2 characters denote the platform */ platform = badges[j].slice(0, 2) /* first 2 characters denote the platform */
bname = badges[j].slice(2) /* the rest is the badge name itself */ bname = badges[j].slice(2) /* the rest is the badge name itself */
html += '<img class=badge src="/badge/{platform}/{badgename}">' html += '<img class=badge src="/badge/{platform}/{badgename}">'
.replace('{platform}', platform).replace('{badgename}', bname) .replace('{platform}', platform).replace('{badgename}', bname)
} }
if(icon.length > 0) /* user icon, if present */ if (icon.length > 0) /* user icon, if present */
html += '<img src="' + icon + '">' html += '<img src="' + icon + '">'
html += '</span>' html += '</span>'
html += '<span class=timestamp>' + timestamp + '</span>' html += '<span class=timestamp>' + timestamp + '</span>'
html += '<span class=username' html += '<span class=username'
if(colorpref) html += ' style="color:' + colorpref + '"' if (colorpref) html += ' style="color:' + colorpref + '"'
html += '>' + username + '</span>' html += '>' + username + '</span>'
html += '<span class=msg' + overrideStyles(text) +'>' + handleEmotes(text) + '</span></div>' html += '<span class=msg' + overrideStyles(text) + '>' + handleEmotes(text) + '</span></div>'
document.getElementById("msglist").innerHTML += html document.getElementById("msglist").innerHTML += html
} }
} }
/* autoscroll to the end */ /* autoscroll to the end */
window.setTimeout(function(){window.scrollTo(0, document.body.scrollHeight)}, 150) window.setTimeout(function () { window.scrollTo(0, document.body.scrollHeight) }, 150)
} }
/* entry point, set up an event source */ /* entry point, set up an event source */
window.addEventListener('DOMContentLoaded', function() { window.addEventListener('DOMContentLoaded', function () {
var msgSource = new EventSource('/sse') var msgSource = new EventSource('/sse')
msgSource.onmessage = e => { msgSource.onmessage = e => {
var msg = JSON.parse(e.data) var msg = JSON.parse(e.data)