mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
330 lines
8.8 KiB
Go
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)
|
|
}
|