806 lines
21 KiB
Go
806 lines
21 KiB
Go
package routes
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"kvtube-go/services"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// getAllowedOrigins returns allowed CORS origins from environment variable or defaults
|
|
func getAllowedOrigins() []string {
|
|
originsEnv := os.Getenv("CORS_ALLOWED_ORIGINS")
|
|
if originsEnv == "" {
|
|
// Default: allow localhost for development
|
|
return []string{
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:5011",
|
|
"http://127.0.0.1:5011",
|
|
}
|
|
}
|
|
origins := strings.Split(originsEnv, ",")
|
|
for i := range origins {
|
|
origins[i] = strings.TrimSpace(origins[i])
|
|
}
|
|
return origins
|
|
}
|
|
|
|
// isAllowedOrigin checks if the given origin is in the allowed list
|
|
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
|
for _, allowed := range allowedOrigins {
|
|
if allowed == "*" || allowed == origin {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isAllowedDomain checks if the URL belongs to allowed domains (YouTube/Google)
|
|
func isAllowedDomain(targetURL string) error {
|
|
parsedURL, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Allowed domains for video proxy
|
|
allowedDomains := []string{
|
|
".youtube.com",
|
|
".googlevideo.com",
|
|
".ytimg.com",
|
|
".google.com",
|
|
".gstatic.com",
|
|
}
|
|
|
|
host := strings.ToLower(parsedURL.Hostname())
|
|
|
|
// Check if host matches any allowed domain
|
|
for _, domain := range allowedDomains {
|
|
if strings.HasSuffix(host, domain) || host == strings.TrimPrefix(domain, ".") {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("domain %s not allowed", host)
|
|
}
|
|
|
|
// validateSearchQuery ensures search query contains only safe characters
|
|
func validateSearchQuery(query string) error {
|
|
// Allow alphanumeric, spaces, hyphens, underscores, dots, commas, exclamation marks
|
|
safePattern := regexp.MustCompile(`^[a-zA-Z0-9\s\-_.,!]+$`)
|
|
if !safePattern.MatchString(query) {
|
|
return fmt.Errorf("search query contains invalid characters")
|
|
}
|
|
if len(query) > 200 {
|
|
return fmt.Errorf("search query too long")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Global HTTP client with connection pooling and timeouts
|
|
var httpClient = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
},
|
|
}
|
|
|
|
func SetupRouter() *gin.Engine {
|
|
r := gin.Default()
|
|
|
|
// CORS middleware - restrict to specific origins from environment variable
|
|
allowedOrigins := getAllowedOrigins()
|
|
r.Use(func(c *gin.Context) {
|
|
origin := c.GetHeader("Origin")
|
|
if origin != "" && isAllowedOrigin(origin, allowedOrigins) {
|
|
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
|
}
|
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
if c.Request.Method == "OPTIONS" {
|
|
c.AbortWithStatus(204)
|
|
return
|
|
}
|
|
c.Next()
|
|
})
|
|
|
|
r.GET("/api/health", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
})
|
|
|
|
// API Routes
|
|
api := r.Group("/api")
|
|
{
|
|
api.GET("/search", handleSearch)
|
|
api.GET("/trending", handleTrending)
|
|
api.GET("/get_stream_info", handleGetStreamInfo)
|
|
api.GET("/download", handleDownload)
|
|
api.GET("/download-file", handleDownloadFile)
|
|
api.GET("/transcript", handleTranscript)
|
|
api.GET("/comments", handleComments)
|
|
api.GET("/channel/videos", handleChannelVideos)
|
|
api.GET("/channel/info", handleChannelInfo)
|
|
api.GET("/related", handleRelatedVideos)
|
|
api.GET("/formats", handleGetFormats)
|
|
api.GET("/qualities", handleGetQualities)
|
|
api.GET("/stream", handleGetStreamByQuality)
|
|
|
|
// History routes
|
|
api.POST("/history", handlePostHistory)
|
|
api.GET("/history", handleGetHistory)
|
|
api.GET("/suggestions", handleGetSuggestions)
|
|
|
|
// Subscription routes
|
|
api.POST("/subscribe", handleSubscribe)
|
|
api.DELETE("/subscribe", handleUnsubscribe)
|
|
api.GET("/subscribe", handleCheckSubscription)
|
|
api.GET("/subscriptions", handleGetSubscriptions)
|
|
}
|
|
|
|
r.GET("/video_proxy", handleVideoProxy)
|
|
|
|
return r
|
|
}
|
|
|
|
func handleSearch(c *gin.Context) {
|
|
query := c.Query("q")
|
|
if query == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
|
return
|
|
}
|
|
|
|
// Validate search query for security
|
|
if err := validateSearchQuery(query); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
limit := 20
|
|
if l := c.Query("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
results, err := services.SearchVideos(query, limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
func handleTrending(c *gin.Context) {
|
|
// Basic mock implementation for now
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"data": []gin.H{
|
|
{
|
|
"id": "trending",
|
|
"title": "Currently Trending",
|
|
"icon": "fire",
|
|
"videos": []gin.H{},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func handleGetStreamInfo(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
if videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
return
|
|
}
|
|
|
|
info, qualities, audioURL, err := services.GetFullStreamData(videoID)
|
|
if err != nil {
|
|
log.Printf("GetFullStreamData Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
|
|
return
|
|
}
|
|
|
|
// Build quality options for frontend
|
|
var qualityOptions []gin.H
|
|
bestURL := info.StreamURL
|
|
bestHeight := 0
|
|
|
|
for _, q := range qualities {
|
|
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
|
|
audioProxyURL := ""
|
|
if q.AudioURL != "" {
|
|
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
|
|
}
|
|
qualityOptions = append(qualityOptions, gin.H{
|
|
"label": q.Label,
|
|
"height": q.Height,
|
|
"url": proxyURL,
|
|
"audio_url": audioProxyURL,
|
|
"is_hls": q.IsHLS,
|
|
"has_audio": q.HasAudio,
|
|
})
|
|
if q.Height > bestHeight {
|
|
bestHeight = q.Height
|
|
bestURL = q.URL
|
|
}
|
|
}
|
|
|
|
// If we found qualities, use the best one
|
|
streamURL := info.StreamURL
|
|
if bestURL != "" {
|
|
streamURL = bestURL
|
|
}
|
|
|
|
proxyURL := "/video_proxy?url=" + url.QueryEscape(streamURL)
|
|
|
|
// Get audio URL for the response
|
|
audioProxyURL := ""
|
|
if audioURL != "" {
|
|
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"original_url": info.StreamURL,
|
|
"stream_url": proxyURL,
|
|
"audio_url": audioProxyURL,
|
|
"title": info.Title,
|
|
"description": info.Description,
|
|
"uploader": info.Uploader,
|
|
"channel_id": info.ChannelID,
|
|
"uploader_id": info.UploaderID,
|
|
"view_count": info.ViewCount,
|
|
"thumbnail": info.Thumbnail,
|
|
"related": []interface{}{},
|
|
"subtitle_url": nil,
|
|
"qualities": qualityOptions,
|
|
"best_quality": bestHeight,
|
|
})
|
|
}
|
|
|
|
func handleDownload(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
if videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
return
|
|
}
|
|
|
|
formatID := c.Query("f")
|
|
|
|
info, err := services.GetDownloadURL(videoID, formatID)
|
|
if err != nil {
|
|
log.Printf("GetDownloadURL Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, info)
|
|
}
|
|
|
|
func handleDownloadFile(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
if videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
return
|
|
}
|
|
|
|
formatID := c.Query("f")
|
|
|
|
// Get the download URL from yt-dlp
|
|
info, err := services.GetDownloadURL(videoID, formatID)
|
|
if err != nil {
|
|
log.Printf("GetDownloadURL Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"})
|
|
return
|
|
}
|
|
|
|
if info.URL == "" {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "No download URL available"})
|
|
return
|
|
}
|
|
|
|
// Create request to the video URL
|
|
req, err := http.NewRequest("GET", info.URL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
|
|
return
|
|
}
|
|
|
|
// Copy range header if present (for partial content/resumable downloads)
|
|
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
|
|
req.Header.Set("Range", rangeHeader)
|
|
}
|
|
|
|
// Set appropriate headers for YouTube
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
|
req.Header.Set("Referer", "https://www.youtube.com/")
|
|
req.Header.Set("Origin", "https://www.youtube.com")
|
|
|
|
// Make the request
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch video: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Copy relevant headers from YouTube response to our response
|
|
for key, values := range resp.Header {
|
|
if key == "Content-Type" || key == "Content-Length" || key == "Content-Range" ||
|
|
key == "Accept-Ranges" || key == "Content-Disposition" {
|
|
for _, value := range values {
|
|
c.Header(key, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set content type based on extension
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
if info.Ext == "mp4" {
|
|
contentType = "video/mp4"
|
|
} else if info.Ext == "webm" {
|
|
contentType = "video/webm"
|
|
} else {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
}
|
|
c.Header("Content-Type", contentType)
|
|
|
|
// Set content disposition for download
|
|
filename := fmt.Sprintf("%s.%s", info.Title, info.Ext)
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
|
|
|
// Copy status code
|
|
c.Status(resp.StatusCode)
|
|
|
|
// Stream the video
|
|
io.Copy(c.Writer, resp.Body)
|
|
}
|
|
|
|
func handleGetFormats(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
if videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
return
|
|
}
|
|
|
|
formats, err := services.GetVideoFormats(videoID)
|
|
if err != nil {
|
|
log.Printf("GetVideoFormats Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video formats"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, formats)
|
|
}
|
|
|
|
func handleGetQualities(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
if videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
return
|
|
}
|
|
|
|
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
|
if err != nil {
|
|
log.Printf("GetVideoQualities Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
|
return
|
|
}
|
|
|
|
var result []gin.H
|
|
for _, q := range qualities {
|
|
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
|
|
audioProxyURL := ""
|
|
if q.AudioURL != "" {
|
|
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
|
|
}
|
|
result = append(result, gin.H{
|
|
"format_id": q.FormatID,
|
|
"label": q.Label,
|
|
"resolution": q.Resolution,
|
|
"height": q.Height,
|
|
"url": proxyURL,
|
|
"audio_url": audioProxyURL,
|
|
"is_hls": q.IsHLS,
|
|
"vcodec": q.VCodec,
|
|
"acodec": q.ACodec,
|
|
"filesize": q.Filesize,
|
|
"has_audio": q.HasAudio,
|
|
})
|
|
}
|
|
|
|
// Also return the best audio URL separately
|
|
audioProxyURL := ""
|
|
if audioURL != "" {
|
|
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"qualities": result,
|
|
"audio_url": audioProxyURL,
|
|
})
|
|
}
|
|
|
|
func handleGetStreamByQuality(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
if videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
return
|
|
}
|
|
|
|
heightStr := c.Query("q")
|
|
height := 0
|
|
if heightStr != "" {
|
|
if parsed, err := strconv.Atoi(heightStr); err == nil {
|
|
height = parsed
|
|
}
|
|
}
|
|
|
|
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
|
if err != nil {
|
|
log.Printf("GetVideoQualities Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
|
return
|
|
}
|
|
|
|
if len(qualities) == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "No qualities available"})
|
|
return
|
|
}
|
|
|
|
var selected *services.QualityFormat
|
|
for i := range qualities {
|
|
if qualities[i].Height == height {
|
|
selected = &qualities[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if selected == nil {
|
|
selected = &qualities[0]
|
|
}
|
|
|
|
proxyURL := "/video_proxy?url=" + url.QueryEscape(selected.URL)
|
|
|
|
audioProxyURL := ""
|
|
if selected.AudioURL != "" {
|
|
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(selected.AudioURL)
|
|
} else if audioURL != "" {
|
|
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"stream_url": proxyURL,
|
|
"audio_url": audioProxyURL,
|
|
"has_audio": selected.HasAudio,
|
|
"quality": gin.H{
|
|
"label": selected.Label,
|
|
"height": selected.Height,
|
|
"is_hls": selected.IsHLS,
|
|
},
|
|
})
|
|
}
|
|
|
|
func handleRelatedVideos(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
title := c.Query("title")
|
|
uploader := c.Query("uploader")
|
|
|
|
if title == "" && videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"})
|
|
return
|
|
}
|
|
|
|
limitStr := c.Query("limit")
|
|
limit := 10
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
|
|
videos, err := services.GetRelatedVideos(title, uploader, limit)
|
|
if err != nil {
|
|
log.Printf("GetRelatedVideos Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, videos)
|
|
}
|
|
|
|
func handleTranscript(c *gin.Context) {
|
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"})
|
|
}
|
|
|
|
func handleComments(c *gin.Context) {
|
|
videoID := c.Query("v")
|
|
if videoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
|
|
return
|
|
}
|
|
|
|
limit := 20
|
|
if l := c.Query("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
comments, err := services.GetComments(videoID, limit)
|
|
if err != nil {
|
|
log.Printf("GetComments Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, comments)
|
|
}
|
|
|
|
func handleChannelInfo(c *gin.Context) {
|
|
channelID := c.Query("id")
|
|
if channelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
|
|
return
|
|
}
|
|
|
|
info, err := services.GetChannelInfo(channelID)
|
|
if err != nil {
|
|
log.Printf("GetChannelInfo Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, info)
|
|
}
|
|
|
|
func handleChannelVideos(c *gin.Context) {
|
|
channelID := c.Query("id")
|
|
if channelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
|
|
return
|
|
}
|
|
|
|
limitStr := c.Query("limit")
|
|
limit := 30
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
|
|
videos, err := services.GetChannelVideos(channelID, limit)
|
|
if err != nil {
|
|
log.Printf("GetChannelVideos Error: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, videos)
|
|
}
|
|
|
|
func handleVideoProxy(c *gin.Context) {
|
|
targetURL := c.Query("url")
|
|
if targetURL == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No URL provided"})
|
|
return
|
|
}
|
|
|
|
// SSRF Protection: Validate target domain
|
|
if err := isAllowedDomain(targetURL); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "URL domain not allowed"})
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", targetURL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
|
|
return
|
|
}
|
|
|
|
// Forward standard headers
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
|
req.Header.Set("Referer", "https://www.youtube.com/")
|
|
req.Header.Set("Origin", "https://www.youtube.com")
|
|
|
|
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
|
|
req.Header.Set("Range", rangeHeader)
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
baseURL := targetURL[:strings.LastIndex(targetURL, "/")]
|
|
|
|
isManifest := strings.Contains(strings.ToLower(contentType), "mpegurl") ||
|
|
strings.HasSuffix(targetURL, ".m3u8") ||
|
|
strings.Contains(targetURL, ".m3u8")
|
|
|
|
if isManifest && (resp.StatusCode == 200 || resp.StatusCode == 206) {
|
|
// Rewrite M3U8 Manifest
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
var newLines []string
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
fullURL := line
|
|
if !strings.HasPrefix(line, "http") {
|
|
fullURL = baseURL + "/" + line
|
|
}
|
|
encodedURL := url.QueryEscape(fullURL)
|
|
newLines = append(newLines, "/video_proxy?url="+encodedURL)
|
|
} else {
|
|
newLines = append(newLines, line)
|
|
}
|
|
}
|
|
|
|
rewrittenContent := strings.Join(newLines, "\n")
|
|
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewrittenContent))
|
|
return
|
|
}
|
|
|
|
// Stream binary video data
|
|
for k, v := range resp.Header {
|
|
logKey := strings.ToLower(k)
|
|
if logKey != "content-encoding" && logKey != "transfer-encoding" && logKey != "connection" && !strings.HasPrefix(logKey, "access-control-") {
|
|
c.Writer.Header()[k] = v
|
|
}
|
|
}
|
|
c.Writer.WriteHeader(resp.StatusCode)
|
|
io.Copy(c.Writer, resp.Body)
|
|
}
|
|
|
|
func handlePostHistory(c *gin.Context) {
|
|
var body struct {
|
|
VideoID string `json:"video_id"`
|
|
Title string `json:"title"`
|
|
Thumbnail string `json:"thumbnail"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
if body.VideoID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
|
return
|
|
}
|
|
|
|
err := services.AddToHistory(body.VideoID, body.Title, body.Thumbnail)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update history"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
}
|
|
|
|
func handleGetHistory(c *gin.Context) {
|
|
limitStr := c.Query("limit")
|
|
limit := 50
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
|
|
history, err := services.GetHistory(limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
|
|
return
|
|
}
|
|
|
|
// Make the API response shape match the VideoData shape the frontend expects
|
|
// We'll reconstruct a basic VideoData-like array for the frontend
|
|
var results []services.VideoData
|
|
for _, h := range history {
|
|
results = append(results, services.VideoData{
|
|
ID: h.ID,
|
|
Title: h.Title,
|
|
Thumbnail: h.Thumbnail,
|
|
Uploader: "History", // Just a placeholder
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
func handleGetSuggestions(c *gin.Context) {
|
|
limitStr := c.Query("limit")
|
|
limit := 20
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
|
|
suggestions, err := services.GetSuggestions(limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, suggestions)
|
|
}
|
|
|
|
func handleSubscribe(c *gin.Context) {
|
|
var body struct {
|
|
ChannelID string `json:"channel_id"`
|
|
ChannelName string `json:"channel_name"`
|
|
ChannelAvatar string `json:"channel_avatar"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
|
return
|
|
}
|
|
|
|
if body.ChannelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
|
return
|
|
}
|
|
|
|
err := services.SubscribeChannel(body.ChannelID, body.ChannelName, body.ChannelAvatar)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "subscribed"})
|
|
}
|
|
|
|
func handleUnsubscribe(c *gin.Context) {
|
|
channelID := c.Query("channel_id")
|
|
if channelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
|
return
|
|
}
|
|
|
|
err := services.UnsubscribeChannel(channelID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "unsubscribed"})
|
|
}
|
|
|
|
func handleCheckSubscription(c *gin.Context) {
|
|
channelID := c.Query("channel_id")
|
|
if channelID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
|
return
|
|
}
|
|
|
|
subscribed, err := services.IsSubscribed(channelID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subscription"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"subscribed": subscribed})
|
|
}
|
|
|
|
func handleGetSubscriptions(c *gin.Context) {
|
|
subs, err := services.GetSubscriptions()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, subs)
|
|
}
|