download.go
⎇
Raw
1package service
2
3import (
4 "database/sql"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "vidarchive/internal/config"
13 "vidarchive/internal/models"
14 "vidarchive/internal/repository"
15)
16
17type DownloadService struct {
18 repo *repository.DownloadRepository
19 mediaRepo *repository.MediaRepository
20 presetSvc *PresetService
21 cfg *config.Config
22}
23
24func NewDownloadService(repo *repository.DownloadRepository, mediaRepo *repository.MediaRepository, presetSvc *PresetService, cfg *config.Config) *DownloadService {
25 return &DownloadService{
26 repo: repo,
27 mediaRepo: mediaRepo,
28 presetSvc: presetSvc,
29 cfg: cfg,
30 }
31}
32
33func (s *DownloadService) Create(url string, presetID *int64, formatOverride, customFlags string) (*models.Download, error) {
34 d := &models.Download{
35 URL: url,
36 Status: "queued",
37 FormatOverride: formatOverride,
38 CustomFlags: customFlags,
39 }
40
41 if presetID != nil {
42 d.PresetID = sqlNullInt64(*presetID)
43 }
44
45 if err := s.repo.Create(d); err != nil {
46 return nil, err
47 }
48 return d, nil
49}
50
51func (s *DownloadService) GetByID(id int64) (*models.Download, error) {
52 return s.repo.GetByID(id)
53}
54
55func (s *DownloadService) GetAll(status, sortBy string) ([]*models.Download, error) {
56 return s.repo.GetAll(status, sortBy)
57}
58
59func (s *DownloadService) GetQueued(limit int) ([]*models.Download, error) {
60 return s.repo.GetQueued(limit)
61}
62
63func (s *DownloadService) Delete(id int64) error {
64 return s.repo.Delete(id)
65}
66
67func (s *DownloadService) ListFormats(url string) ([]*models.FormatInfo, error) {
68 cmd := exec.Command(s.cfg.YTDLPPath, "-F", "--no-warnings", url)
69 output, err := cmd.CombinedOutput()
70 if err != nil {
71 return nil, fmt.Errorf("yt-dlp -F failed: %w\nOutput: %s", err, string(output))
72 }
73
74 return parseFormatList(string(output)), nil
75}
76
77func (s *DownloadService) ExecuteDownload(d *models.Download) error {
78 if err := s.repo.MarkStarted(d.ID); err != nil {
79 return err
80 }
81
82 var preset *models.Preset
83 var err error
84
85 if d.PresetID.Valid {
86 preset, err = s.presetSvc.GetByID(d.PresetID.Int64)
87 if err != nil {
88 preset, _ = s.presetSvc.GetDefault()
89 }
90 } else {
91 preset, _ = s.presetSvc.GetDefault()
92 }
93
94 if preset == nil {
95 preset = &models.Preset{
96 OutputTemplate: "%(title)s [%(id)s].%(ext)s",
97 }
98 }
99
100 args := s.presetSvc.BuildArgs(preset, d.FormatOverride, d.CustomFlags)
101 args = append(args, "--newline", "--progress", "-P", s.cfg.DownloadDir)
102 args = append(args, d.URL)
103
104 cmd := exec.Command(s.cfg.YTDLPPath, args...)
105
106 // Set up log capture
107 var logBuilder strings.Builder
108
109 // Start the command
110 if err := cmd.Start(); err != nil {
111 s.repo.MarkError(d.ID, err.Error())
112 return err
113 }
114
115 // TODO: In a real implementation, we'd parse stdout in real-time
116 // For now, just wait and capture output
117 output, err := cmd.CombinedOutput()
118 logBuilder.Write(output)
119
120 s.repo.UpdateLogs(d.ID, logBuilder.String())
121
122 if err != nil {
123 s.repo.MarkError(d.ID, err.Error())
124 return err
125 }
126
127 // Mark as completed
128 if err := s.repo.MarkCompleted(d.ID, "completed"); err != nil {
129 return err
130 }
131
132 // Scan for new media files
133 s.scanForNewMedia(d.URL)
134
135 return nil
136}
137
138func (s *DownloadService) scanForNewMedia(url string) {
139 // Simple scan - in production, parse yt-dlp output to know exactly what was downloaded
140 time.Sleep(100 * time.Millisecond) // Give filesystem a moment
141
142 // Walk download dir and add any new files
143 filepath.Walk(s.cfg.DownloadDir, func(path string, info os.FileInfo, err error) error {
144 if err != nil || info.IsDir() {
145 return nil
146 }
147
148 relPath, _ := filepath.Rel(s.cfg.DownloadDir, path)
149 relPath = filepath.ToSlash(relPath)
150
151 _, err = s.mediaRepo.GetByRelativePath(relPath)
152 if err == nil {
153 return nil // already exists
154 }
155
156 ext := strings.ToLower(filepath.Ext(path))
157 isAudio := ext == ".mp3" || ext == ".m4a" || ext == ".ogg" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
158 isVideo := ext == ".mp4" || ext == ".webm" || ext == ".mkv" || ext == ".avi" || ext == ".mov"
159
160 if !isAudio && !isVideo {
161 return nil
162 }
163
164 basePath := strings.TrimSuffix(path, ext)
165 infoJSONPath := basePath + ".info.json"
166 if _, err := os.Stat(infoJSONPath); err != nil {
167 infoJSONPath = ""
168 }
169
170 media := &models.Media{
171 URL: url,
172 Filepath: path,
173 RelativePath: relPath,
174 IsAudio: isAudio,
175 HasEmbeddedThumbnail: true, // yt-dlp usually embeds when requested
176 InfoJSONPath: infoJSONPath,
177 Title: strings.TrimSuffix(filepath.Base(path), ext),
178 }
179
180 s.mediaRepo.Create(media)
181 return nil
182 })
183}
184
185func parseFormatList(output string) []*models.FormatInfo {
186 // Simple parser for yt-dlp -F output
187 // This is a basic implementation - yt-dlp format can vary
188 lines := strings.Split(output, "\n")
189 var formats []*models.FormatInfo
190
191 inFormats := false
192 for _, line := range lines {
193 line = strings.TrimSpace(line)
194 if line == "" {
195 continue
196 }
197
198 // Look for the format table header
199 if strings.Contains(line, "ID") && strings.Contains(line, "EXT") {
200 inFormats = true
201 continue
202 }
203
204 if !inFormats {
205 continue
206 }
207
208 // Parse format line
209 parts := strings.Fields(line)
210 if len(parts) >= 4 {
211 format := &models.FormatInfo{
212 ID: parts[0],
213 Ext: parts[1],
214 }
215
216 // Try to extract resolution
217 for i, part := range parts {
218 if strings.Contains(part, "x") && !strings.Contains(part, "http") {
219 format.Resolution = part
220 if i+1 < len(parts) {
221 format.FPS = parts[i+1]
222 }
223 break
224 }
225 }
226
227 // Note is everything after resolution
228 if len(parts) > 3 {
229 format.Note = strings.Join(parts[3:], " ")
230 }
231
232 formats = append(formats, format)
233 }
234 }
235
236 return formats
237}
238
239func sqlNullInt64(v int64) sql.NullInt64 {
240 return sql.NullInt64{Int64: v, Valid: true}
241}
242