Files
MyClub/internal/controllers/youtube_controller.go
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

330 lines
8.8 KiB
Go

package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type YouTubeController struct {
DB *gorm.DB
}
func NewYouTubeController(db *gorm.DB) *YouTubeController {
return &YouTubeController{DB: db}
}
type YouTubeVideo struct {
VideoID string `json:"video_id"`
Title string `json:"title"`
ThumbnailURL string `json:"thumbnail_url"`
ViewsText string `json:"views_text,omitempty"`
Views int64 `json:"views,omitempty"`
PublishedText string `json:"published_text,omitempty"`
PublishedDate string `json:"published_date,omitempty"` // YYYY-MM-DD
}
type YouTubeChannelPayload struct {
Channel string `json:"channel"`
ChannelURL string `json:"channel_url"`
SubscribersText string `json:"subscribers_text,omitempty"`
Subscribers int64 `json:"subscribers,omitempty"`
Videos []YouTubeVideo `json:"videos"`
}
// GetYouTubeVideos returns cached YouTube channel videos
func (yc *YouTubeController) GetYouTubeVideos(c *gin.Context) {
// Try to load from cache first
cacheFile := filepath.Join("cache", "prefetch", "youtube_channel.json")
if data, err := os.ReadFile(cacheFile); err == nil {
var payload YouTubeChannelPayload
if err := json.Unmarshal(data, &payload); err == nil && len(payload.Videos) > 0 {
c.JSON(http.StatusOK, payload)
return
}
}
// If no cache, try to fetch and cache
var settings models.Settings
if err := yc.DB.First(&settings).Error; err == nil {
youtubeURL := settings.YoutubeURL
if youtubeURL == "" {
c.JSON(http.StatusNoContent, gin.H{})
return
}
// Fetch and cache
payload, err := yc.fetchYouTubeChannel(youtubeURL)
if err != nil {
logger.Error("Failed to fetch YouTube channel: %v", err)
c.JSON(http.StatusNoContent, gin.H{})
return
}
// Save to cache
if err := yc.saveYouTubeCache(payload); err != nil {
logger.Warn("Failed to save YouTube cache: %v", err)
}
c.JSON(http.StatusOK, payload)
return
}
c.JSON(http.StatusNoContent, gin.H{})
}
// fetchYouTubeChannel fetches videos from YouTube channel URL
func (yc *YouTubeController) fetchYouTubeChannel(channelURL string) (*YouTubeChannelPayload, error) {
// Parse channel URL to extract channel handle or ID
channelHandle := extractYouTubeHandle(channelURL)
if channelHandle == "" {
return nil, fmt.Errorf("invalid YouTube URL")
}
// Fetch the channel page HTML
resp, err := http.Get(channelURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
html := string(body)
// Parse channel data from HTML
payload := &YouTubeChannelPayload{
Channel: channelHandle,
ChannelURL: channelURL,
Videos: []YouTubeVideo{},
}
// Extract channel title
if match := regexp.MustCompile(`<meta name="title" content="([^"]+)"`).FindStringSubmatch(html); len(match) > 1 {
payload.Channel = match[1]
}
// Extract videos from initial data
videos := parseYouTubeVideosFromHTML(html)
payload.Videos = videos
return payload, nil
}
// parseYouTubeVideosFromHTML extracts video data from YouTube HTML
func parseYouTubeVideosFromHTML(html string) []YouTubeVideo {
videos := []YouTubeVideo{}
// Find ytInitialData JSON
re := regexp.MustCompile(`var ytInitialData = ({.+?});`)
matches := re.FindStringSubmatch(html)
if len(matches) < 2 {
return videos
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(matches[1]), &data); err != nil {
return videos
}
// Navigate to video items (this is a simplified version - YouTube's structure is complex)
// Try to find gridRenderer or richItemRenderer
tabs, ok := data["contents"].(map[string]interface{})
if !ok {
return videos
}
// Try multiple paths to find videos
extractVideosRecursive(tabs, &videos, 0, 20) // limit recursion
return videos
}
// extractVideosRecursive recursively searches for video data
func extractVideosRecursive(obj interface{}, videos *[]YouTubeVideo, depth, maxDepth int) {
if depth > maxDepth || len(*videos) >= 20 {
return
}
switch v := obj.(type) {
case map[string]interface{}:
// Check if this is a video renderer
if videoId, ok := v["videoId"].(string); ok {
video := YouTubeVideo{
VideoID: videoId,
ThumbnailURL: fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", videoId),
}
// Extract title
if title, ok := v["title"].(map[string]interface{}); ok {
if runs, ok := title["runs"].([]interface{}); ok && len(runs) > 0 {
if run, ok := runs[0].(map[string]interface{}); ok {
if text, ok := run["text"].(string); ok {
video.Title = text
}
}
} else if simpleText, ok := title["simpleText"].(string); ok {
video.Title = simpleText
}
}
// Extract views
if viewCountText, ok := v["viewCountText"].(map[string]interface{}); ok {
if simpleText, ok := viewCountText["simpleText"].(string); ok {
video.ViewsText = simpleText
video.Views = parseViews(simpleText)
}
}
// Extract published date
if publishedTimeText, ok := v["publishedTimeText"].(map[string]interface{}); ok {
if simpleText, ok := publishedTimeText["simpleText"].(string); ok {
video.PublishedText = simpleText
video.PublishedDate = estimateDate(simpleText)
}
}
if video.Title != "" {
*videos = append(*videos, video)
}
}
// Recurse into nested objects
for _, val := range v {
extractVideosRecursive(val, videos, depth+1, maxDepth)
}
case []interface{}:
for _, item := range v {
extractVideosRecursive(item, videos, depth+1, maxDepth)
}
}
}
// extractYouTubeHandle extracts channel handle from URL
func extractYouTubeHandle(url string) string {
url = strings.TrimSpace(url)
if strings.Contains(url, "/@") {
parts := strings.Split(url, "/@")
if len(parts) > 1 {
return strings.Split(parts[1], "/")[0]
}
}
if strings.Contains(url, "/channel/") {
parts := strings.Split(url, "/channel/")
if len(parts) > 1 {
return strings.Split(parts[1], "/")[0]
}
}
return ""
}
// parseViews converts view count text to number
func parseViews(text string) int64 {
text = strings.ToLower(text)
text = strings.ReplaceAll(text, " ", "")
text = strings.ReplaceAll(text, ",", "")
var multiplier int64 = 1
if strings.Contains(text, "k") {
multiplier = 1000
text = strings.ReplaceAll(text, "k", "")
} else if strings.Contains(text, "m") {
multiplier = 1000000
text = strings.ReplaceAll(text, "m", "")
} else if strings.Contains(text, "mil") {
multiplier = 1000000
text = strings.ReplaceAll(text, "mil", "")
}
// Extract first number
re := regexp.MustCompile(`[\d.]+`)
match := re.FindString(text)
if match == "" {
return 0
}
val, _ := strconv.ParseFloat(match, 64)
return int64(val * float64(multiplier))
}
// estimateDate converts relative time text to YYYY-MM-DD
func estimateDate(text string) string {
now := time.Now()
text = strings.ToLower(text)
if strings.Contains(text, "hour") || strings.Contains(text, "hodina") {
return now.Format("2006-01-02")
}
if strings.Contains(text, "day") || strings.Contains(text, "den") || strings.Contains(text, "dní") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
days, _ := strconv.Atoi(match)
return now.AddDate(0, 0, -days).Format("2006-01-02")
}
return now.AddDate(0, 0, -1).Format("2006-01-02")
}
if strings.Contains(text, "week") || strings.Contains(text, "týden") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
weeks, _ := strconv.Atoi(match)
return now.AddDate(0, 0, -weeks*7).Format("2006-01-02")
}
return now.AddDate(0, 0, -7).Format("2006-01-02")
}
if strings.Contains(text, "month") || strings.Contains(text, "měsíc") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
months, _ := strconv.Atoi(match)
return now.AddDate(0, -months, 0).Format("2006-01-02")
}
return now.AddDate(0, -1, 0).Format("2006-01-02")
}
if strings.Contains(text, "year") || strings.Contains(text, "rok") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
years, _ := strconv.Atoi(match)
return now.AddDate(-years, 0, 0).Format("2006-01-02")
}
return now.AddDate(-1, 0, 0).Format("2006-01-02")
}
return now.Format("2006-01-02")
}
// saveYouTubeCache saves YouTube data to cache file
func (yc *YouTubeController) saveYouTubeCache(payload *YouTubeChannelPayload) error {
cacheDir := filepath.Join("cache", "prefetch")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
cacheFile := filepath.Join(cacheDir, "youtube_channel.json")
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(cacheFile, data, 0644)
}