Files
DownLink/backend/services/video.go
Arkaprabha Chakraborty 1a0330537a feat: caching & logging
2025-07-09 21:55:32 +05:30

250 lines
6.7 KiB
Go

package services
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
type VideoService struct {
cacheDir string
}
func NewVideoService() *VideoService {
// Create cache directory if it doesn't exist
cacheDir := "./cache"
if err := os.MkdirAll(cacheDir, 0755); err != nil {
slog.Error("Failed to create cache directory", "path", cacheDir, "error", err)
cacheDir = "" // Disable caching if we can't create directory
slog.Warn("Caching disabled due to directory creation failure")
} else {
slog.Info("Cache directory initialized", "path", cacheDir)
}
return &VideoService{
cacheDir: cacheDir,
}
}
func (vs *VideoService) extractWatchID(url string) string {
// YouTube watch ID pattern
youtubePattern := regexp.MustCompile(`(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})`)
if match := youtubePattern.FindStringSubmatch(url); len(match) > 1 {
return match[1]
}
// Instagram pattern
instagramPattern := regexp.MustCompile(`instagram\.com/p/([a-zA-Z0-9_-]+)`)
if match := instagramPattern.FindStringSubmatch(url); len(match) > 1 {
return match[1]
}
// Fallback: use a hash of the URL
return fmt.Sprintf("hash_%x", len(url))
}
func (vs *VideoService) generateCacheFileName(url, quality string) string {
watchID := vs.extractWatchID(url)
// Remove 'p' from quality (e.g., "720p" -> "720")
cleanQuality := strings.TrimSuffix(quality, "p")
return fmt.Sprintf("%s_%s.mp4", watchID, cleanQuality)
}
func (vs *VideoService) DownloadVideo(url, quality string) (string, error) {
// Check cache first
if vs.cacheDir != "" {
cacheFileName := vs.generateCacheFileName(url, quality)
cachePath := filepath.Join(vs.cacheDir, cacheFileName)
// Check if cached file exists
if _, err := os.Stat(cachePath); err == nil {
slog.Info("Cache hit", "url", url, "quality", quality, "file", cachePath)
return cachePath, nil
}
}
slog.Info("Cache miss, downloading video", "url", url, "quality", quality)
// Determine output path
var outputPath string
if vs.cacheDir != "" {
// Use cache directory with watch_id+quality naming
cacheFileName := vs.generateCacheFileName(url, quality)
outputPath = filepath.Join(vs.cacheDir, cacheFileName)
} else {
// Fallback to temporary directory
tmpDir, err := os.MkdirTemp("", "dl_")
if err != nil {
return "", fmt.Errorf("failed to create temporary directory: %v", err)
}
watchID := vs.extractWatchID(url)
cleanQuality := strings.TrimSuffix(quality, "p")
outputPath = filepath.Join(tmpDir, fmt.Sprintf("%s_%s.mp4", watchID, cleanQuality))
}
quality = quality[:len(quality)-1]
var mergedFormat string
var cookies string
if strings.Contains(url, "instagram.com/") {
mergedFormat = fmt.Sprintf("bestvideo[width<=%s]+bestaudio/best", quality)
cookies = "instagram.txt"
} else {
mergedFormat = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]", quality, quality)
cookies = "youtube.txt"
}
cookiePath := filepath.Join(".", cookies)
if _, err := os.Stat(cookiePath); os.IsNotExist(err) {
slog.Error("Cookie file not found", "path", cookiePath)
return "", fmt.Errorf("cookie file %s not found", cookiePath)
}
slog.Info("Starting yt-dlp download",
"url", url,
"quality", quality,
"format", mergedFormat,
"cookies", cookiePath,
"output", outputPath)
cmdDownload := exec.Command("./venv/bin/python3", "-m", "yt_dlp", "--cookies", cookiePath, "-f", mergedFormat, "--merge-output-format", "mp4", "-o", outputPath, url)
output, err := cmdDownload.CombinedOutput()
if err != nil {
slog.Error("yt-dlp download failed",
"url", url,
"error", err,
"output", string(output))
return "", fmt.Errorf("failed to download video and audio: %v\nOutput: %s", err, string(output))
}
slog.Info("yt-dlp download completed", "url", url, "output", string(output))
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
slog.Error("Output file was not created", "path", outputPath)
return "", fmt.Errorf("video file was not created")
}
slog.Info("Video downloaded successfully", "path", outputPath)
return outputPath, nil
}
func (vs *VideoService) CleanupTempDir(path string) {
// Only cleanup if it's a temporary download (contains "dl_" in path)
if strings.Contains(path, "dl_") {
dir := filepath.Dir(path)
if err := os.RemoveAll(dir); err != nil {
slog.Error("Failed to clean up temporary directory", "path", dir, "error", err)
} else {
slog.Info("Temporary directory cleaned up", "path", dir)
}
}
}
// CleanupExpiredCache removes cached files that are older than the specified duration
func (vs *VideoService) CleanupExpiredCache(maxAge time.Duration) error {
if vs.cacheDir == "" {
slog.Debug("Cache cleanup skipped - caching disabled")
return nil
}
files, err := os.ReadDir(vs.cacheDir)
if err != nil {
return fmt.Errorf("failed to read cache directory: %v", err)
}
cutoff := time.Now().Add(-maxAge)
var removedCount int
var totalSize int64
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".mp4") {
continue
}
filePath := filepath.Join(vs.cacheDir, file.Name())
info, err := os.Stat(filePath)
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
if err := os.Remove(filePath); err != nil {
slog.Error("Failed to remove expired cache file", "path", filePath, "error", err)
} else {
removedCount++
totalSize += info.Size()
slog.Debug("Removed expired cache file", "path", filePath, "size", info.Size())
}
}
}
if removedCount > 0 {
slog.Info("Cache cleanup completed", "files_removed", removedCount, "total_size_removed", totalSize)
} else {
slog.Debug("Cache cleanup completed - no expired files found")
}
return nil
}
// GetCacheDir returns the cache directory path
func (vs *VideoService) GetCacheDir() string {
return vs.cacheDir
}
// GetCacheStats returns cache statistics
func (vs *VideoService) GetCacheStats() map[string]interface{} {
if vs.cacheDir == "" {
return map[string]interface{}{
"status": "disabled",
"total_size": 0,
"files": 0,
}
}
files, err := os.ReadDir(vs.cacheDir)
if err != nil {
slog.Error("Failed to read cache directory for stats", "path", vs.cacheDir, "error", err)
return map[string]interface{}{
"status": "error",
"total_size": 0,
"files": 0,
}
}
var videoCount int64
var totalSize int64
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".mp4") {
continue
}
filePath := filepath.Join(vs.cacheDir, file.Name())
info, err := os.Stat(filePath)
if err != nil {
continue
}
videoCount++
totalSize += info.Size()
}
// Convert bytes to MB
totalSizeMB := totalSize
return map[string]interface{}{
"status": "enabled",
"total_size": totalSizeMB,
"files": videoCount,
}
}