This commit is contained in:
2026-01-31 00:37:01 +01:00
commit 395b85bc02
4 changed files with 397 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type ChatEntry struct {
MessageID string
PublishedAt time.Time
AuthorName string
AuthorChannelID string
Message string
ProfileImageURL string
AuthorIsVerified bool
AuthorIsMod bool
AuthorIsOwner bool
}
func GetLiveChatId(apiKey, link string) (liveChatId string, err error) {
if strings.TrimSpace(apiKey) == "" {
return "", errors.New("apiKey is empty")
}
videoID, err := extractVideoID(link)
if err != nil {
return "", err
}
endpoint := "https://www.googleapis.com/youtube/v3/videos"
u, _ := url.Parse(endpoint)
q := u.Query()
q.Set("part", "liveStreamingDetails")
q.Set("id", videoID)
q.Set("key", apiKey)
u.RawQuery = q.Encode()
body, status, err := httpGet(u.String())
if err != nil {
return "", err
}
if status != http.StatusOK {
return "", parseYouTubeError(status, body)
}
var resp videosListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("decode videos.list response: %w", err)
}
if len(resp.Items) == 0 {
return "", fmt.Errorf("no video found for id=%s", videoID)
}
liveChatId = resp.Items[0].LiveStreamingDetails.ActiveLiveChatID
if strings.TrimSpace(liveChatId) == "" {
return "", errors.New("activeLiveChatId is empty (stream may not be live or chat may be disabled)")
}
return liveChatId, nil
}
// Returns:
// - entries: messages in this page
// - nextPageToken: store and pass on the next call
// - err
func GetLiveChat(apiKey, liveChatId, pageToken string) ([]ChatEntry, string, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, "", errors.New("apiKey is empty")
}
if strings.TrimSpace(liveChatId) == "" {
return nil, "", errors.New("liveChatId is empty")
}
endpoint := "https://www.googleapis.com/youtube/v3/liveChat/messages"
u, _ := url.Parse(endpoint)
q := u.Query()
q.Set("part", "snippet,authorDetails")
q.Set("liveChatId", liveChatId)
q.Set("maxResults", "500")
q.Set("key", apiKey)
if strings.TrimSpace(pageToken) != "" {
q.Set("pageToken", pageToken)
}
u.RawQuery = q.Encode()
body, status, err := httpGet(u.String())
if err != nil {
return nil, "", err
}
if status != http.StatusOK {
return nil, "", parseYouTubeError(status, body)
}
var resp liveChatMessagesListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, "", fmt.Errorf("decode liveChatMessages.list response: %w", err)
}
entries := make([]ChatEntry, 0, len(resp.Items))
for _, it := range resp.Items {
// publishedAt is RFC3339
t, _ := time.Parse(time.RFC3339, it.Snippet.PublishedAt)
entries = append(entries, ChatEntry{
MessageID: it.ID,
PublishedAt: t,
AuthorName: it.AuthorDetails.DisplayName,
AuthorChannelID: it.AuthorDetails.ChannelID,
Message: it.Snippet.DisplayMessage,
ProfileImageURL: it.AuthorDetails.ProfileImageURL,
AuthorIsVerified: it.AuthorDetails.IsVerified,
AuthorIsMod: it.AuthorDetails.IsChatModerator,
AuthorIsOwner: it.AuthorDetails.IsChatOwner,
})
}
return entries, resp.NextPageToken, nil
}
/* ----------------------------- Handlers ----------------------------- */
var httpClient = &http.Client{
Timeout: 15 * time.Second,
}
func httpGet(urlStr string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, urlStr, nil)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("http request failed: %w", err)
}
defer resp.Body.Close()
b, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", readErr)
}
return b, resp.StatusCode, nil
}
func extractVideoID(input string) (string, error) {
s := strings.TrimSpace(input)
if s == "" {
return "", errors.New("link/videoId is empty")
}
// If it doesn't look like a URL, assume it's already a videoId.
if !strings.Contains(s, "://") && !strings.Contains(s, "youtube.") && !strings.Contains(s, "youtu.be") {
return s, nil
}
u, err := url.Parse(s)
if err != nil {
return "", fmt.Errorf("invalid url: %w", err)
}
host := strings.ToLower(u.Host)
// youtu.be/<videoId>
if strings.Contains(host, "youtu.be") {
path := strings.Trim(u.Path, "/")
if path == "" {
return "", errors.New("could not extract videoId from youtu.be url")
}
parts := strings.Split(path, "/")
return parts[0], nil
}
// youtube.com/watch?v=<videoId>
if strings.Contains(host, "youtube.com") {
// Most common: /watch?v=
if strings.EqualFold(u.Path, "/watch") {
vid := u.Query().Get("v")
if vid == "" {
return "", errors.New("missing v= parameter in watch url")
}
return vid, nil
}
// Also handle /live/<videoId> and /shorts/<videoId>
path := strings.Trim(u.Path, "/")
parts := strings.Split(path, "/")
if len(parts) >= 2 {
switch parts[0] {
case "live", "shorts":
if parts[1] != "" {
return parts[1], nil
}
}
}
}
return "", errors.New("could not extract videoId from input")
}
type videosListResponse struct {
Items []struct {
LiveStreamingDetails struct {
ActiveLiveChatID string `json:"activeLiveChatId"`
} `json:"liveStreamingDetails"`
} `json:"items"`
}
type liveChatMessagesListResponse struct {
NextPageToken string `json:"nextPageToken"`
PollingIntervalMillis int `json:"pollingIntervalMillis"`
Items []struct {
ID string `json:"id"`
Snippet struct {
PublishedAt string `json:"publishedAt"`
DisplayMessage string `json:"displayMessage"`
} `json:"snippet"`
AuthorDetails struct {
ChannelID string `json:"channelId"`
DisplayName string `json:"displayName"`
ProfileImageURL string `json:"profileImageUrl"`
IsVerified bool `json:"isVerified"`
IsChatModerator bool `json:"isChatModerator"`
IsChatOwner bool `json:"isChatOwner"`
} `json:"authorDetails"`
} `json:"items"`
}
type ytErrorResponse struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Errors []struct {
Message string `json:"message"`
Reason string `json:"reason"`
} `json:"errors"`
} `json:"error"`
}
func parseYouTubeError(status int, body []byte) error {
var e ytErrorResponse
if err := json.Unmarshal(body, &e); err == nil && e.Error.Message != "" {
// include reason if present
reason := ""
if len(e.Error.Errors) > 0 && e.Error.Errors[0].Reason != "" {
reason = e.Error.Errors[0].Reason
}
if reason != "" {
return fmt.Errorf("youtube api error (http %d): %s (%s)", status, e.Error.Message, reason)
}
return fmt.Errorf("youtube api error (http %d): %s", status, e.Error.Message)
}
// fallback: raw body snippet
raw := strings.TrimSpace(string(body))
if len(raw) > 300 {
raw = raw[:300] + "…"
}
if raw == "" {
raw = "<empty body>"
}
return fmt.Errorf("youtube api error (http %d): %s", status, raw)
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"fmt"
"time"
"sync"
)
func main() {
var apiKey, liveUrl string
fmt.Print("Your Google API Key > ")
n, err := fmt.Scanln(&apiKey)
if err != nil {
fmt.Printf("Failed reading input for %d lines!", n)
}
fmt.Print("Youtube live link > ")
n, err = fmt.Scanln(&liveUrl)
if err != nil {
fmt.Printf("Failed reading input for %d lines!", n)
}
liveChatId, err := GetLiveChatId(apiKey, liveUrl)
if err != nil {
fmt.Println("Failed getting live chat id.")
}
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
queue []ChatEntry
seen = make(map[string]struct{})
)
const (
printLag = 14 * time.Second
pollEvery = 7 * time.Second
lineDelay = 30 * time.Millisecond
)
const (
blue = "\x1b[34m"
yellow = "\x1b[33m"
reset = "\x1b[0m"
)
go func() {
for {
mu.Lock()
for len(queue) == 0 {
cond.Wait()
}
entry := queue[0]
if !entry.PublishedAt.IsZero() {
target := entry.PublishedAt.Add(printLag)
now := time.Now()
if now.Before(target) {
wait := target.Sub(now)
mu.Unlock()
time.Sleep(wait)
continue
}
}
queue = queue[1:]
mu.Unlock()
fmt.Printf("%s%s%s - %s%s%s - %s\n",
blue, entry.PublishedAt.Format("15:04:05"), reset,
yellow, entry.AuthorName, reset,
entry.Message,
)
time.Sleep(lineDelay)
}
}()
go func() {
nextPage := ""
for {
chats, newNext, err := GetLiveChat(apiKey, liveChatId, nextPage)
if err != nil {
fmt.Println("Failed getting live chats:", err)
time.Sleep(pollEvery)
continue
}
nextPage = newNext
mu.Lock()
added := 0
for _, e := range chats {
if e.MessageID != "" {
if _, ok := seen[e.MessageID]; ok {
continue
}
seen[e.MessageID] = struct{}{}
}
queue = append(queue, e)
added++
}
if added > 0 {
cond.Broadcast()
}
mu.Unlock()
time.Sleep(pollEvery)
}
}()
select {}
}