mirror of
https://github.com/agresdominik/yt-chat.git
synced 2026-04-21 18:05:50 +00:00
poc
This commit is contained in:
+271
@@ -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
@@ -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 {}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user