mirror of
https://github.com/agresdominik/yt-chat.git
synced 2026-04-21 10:01:57 +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