commit 395b85bc02e0bc7e993e191c63b8d64dba772efa Author: Dominik Date: Sat Jan 31 00:37:01 2026 +0100 poc diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..13de8ec --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module agresdominik/yt-chat + +go 1.25.6 diff --git a/makefile b/makefile new file mode 100644 index 0000000..e69de29 diff --git a/src/api.go b/src/api.go new file mode 100644 index 0000000..6cfceb9 --- /dev/null +++ b/src/api.go @@ -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/ + 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= + 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/ and /shorts/ + 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 = "" + } + return fmt.Errorf("youtube api error (http %d): %s", status, raw) + +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..07b6938 --- /dev/null +++ b/src/main.go @@ -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 {} + +}