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(` 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) }