media.go
⎇
Raw
1package service
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strconv"
10 "strings"
11
12 "vidarchive/internal/models"
13 "vidarchive/internal/repository"
14)
15
16type MediaService struct {
17 repo *repository.MediaRepository
18 downloadDir string
19 subtitleCacheDir string
20}
21
22func NewMediaService(repo *repository.MediaRepository, downloadDir, subtitleCacheDir string) *MediaService {
23 return &MediaService{repo: repo, downloadDir: downloadDir, subtitleCacheDir: subtitleCacheDir}
24}
25
26func (s *MediaService) GetByID(id int64) (*models.Media, error) {
27 return s.repo.GetByID(id)
28}
29
30func (s *MediaService) GetByRelativePath(relPath string) (*models.Media, error) {
31 return s.repo.GetByRelativePath(relPath)
32}
33
34func (s *MediaService) GetAll(path, sortBy, filter string) ([]*models.Media, []string, error) {
35 if path != "" && !strings.HasSuffix(path, "/") {
36 path += "/"
37 }
38
39 media, err := s.repo.GetAll(path, sortBy, filter)
40 if err != nil {
41 return nil, nil, err
42 }
43
44 folders, err := s.repo.GetFolders(path)
45 if err != nil {
46 return nil, nil, err
47 }
48
49 return media, folders, nil
50}
51
52func (s *MediaService) ScanLibrary() error {
53 return filepath.Walk(s.downloadDir, func(path string, info os.FileInfo, err error) error {
54 if err != nil {
55 return err
56 }
57 if info.IsDir() {
58 return nil
59 }
60
61 // Check if this file is already in DB
62 relPath, _ := filepath.Rel(s.downloadDir, path)
63 relPath = filepath.ToSlash(relPath)
64
65 _, err = s.repo.GetByRelativePath(relPath)
66 if err == nil {
67 return nil // already exists
68 }
69
70 // Determine file type
71 ext := strings.ToLower(filepath.Ext(path))
72 isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus"
73 isVideo := ext == ".mp4" || ext == ".webm" || ext == ".mkv" || ext == ".avi" || ext == ".mov"
74
75 if !isAudio && !isVideo {
76 return nil
77 }
78
79 // Try to find info.json
80 basePath := strings.TrimSuffix(path, ext)
81 infoJSONPath := basePath + ".info.json"
82 if _, err := os.Stat(infoJSONPath); err != nil {
83 infoJSONPath = ""
84 }
85
86 media := &models.Media{
87 Filepath: path,
88 RelativePath: relPath,
89 IsAudio: isAudio,
90 InfoJSONPath: infoJSONPath,
91 }
92
93 // Try to extract title from filename
94 media.Title = strings.TrimSuffix(filepath.Base(path), ext)
95
96 return s.repo.Create(media)
97 })
98}
99
100func (s *MediaService) Delete(id int64) error {
101 media, err := s.repo.GetByID(id)
102 if err != nil {
103 return err
104 }
105
106 // Delete file
107 if err := os.Remove(media.Filepath); err != nil && !os.IsNotExist(err) {
108 return fmt.Errorf("remove file: %w", err)
109 }
110
111 // Delete info json if exists
112 if media.InfoJSONPath != "" {
113 os.Remove(media.InfoJSONPath)
114 }
115
116 // Delete thumbnail if exists
117 thumbPath := s.thumbnailPath(media.Filepath)
118 os.Remove(thumbPath)
119
120 // Delete subtitle cache if exists
121 os.RemoveAll(s.subtitleCachePath(id))
122
123 return s.repo.Delete(id)
124}
125
126func (s *MediaService) thumbnailPath(path string) string {
127 ext := filepath.Ext(path)
128 return strings.TrimSuffix(path, ext) + ".webp"
129}
130
131// ffprobeAttachmentStream represents an attachment stream from ffprobe output
132type ffprobeAttachmentStream struct {
133 Index int `json:"index"`
134 CodecType string `json:"codec_type"`
135 Tags struct {
136 Mimetype string `json:"mimetype"`
137 Filename string `json:"filename"`
138 } `json:"tags"`
139}
140
141type ffprobeAttachmentOutput struct {
142 Streams []ffprobeAttachmentStream `json:"streams"`
143}
144
145// findImageAttachment uses ffprobe to find the best image attachment in a media file.
146// It returns the attachment index (0-based among all attachment streams), or -1 if none found.
147func (s *MediaService) findImageAttachment(path string) int {
148 cmd := exec.Command("ffprobe",
149 "-v", "error",
150 "-show_streams",
151 "-of", "json",
152 path,
153 )
154 output, err := cmd.Output()
155 if err != nil {
156 return -1
157 }
158
159 var probe ffprobeAttachmentOutput
160 if err := json.Unmarshal(output, &probe); err != nil {
161 return -1
162 }
163
164 bestAttachmentIndex := -1
165 bestScore := 0
166 attachmentCount := 0
167
168 for _, stream := range probe.Streams {
169 // Only consider attachment streams with image/* mimetype
170 if stream.CodecType != "attachment" {
171 continue
172 }
173
174 currentAttachmentIndex := attachmentCount
175 attachmentCount++
176
177 if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") {
178 continue
179 }
180
181 score := 0
182 filenameLower := strings.ToLower(stream.Tags.Filename)
183 switch {
184 case strings.Contains(filenameLower, "cover"):
185 score = 100
186 case strings.Contains(filenameLower, "thumbnail"), strings.Contains(filenameLower, "thumb"):
187 score = 80
188 case strings.Contains(filenameLower, "poster"):
189 score = 60
190 case strings.Contains(filenameLower, "art"):
191 score = 40
192 default:
193 score = 20
194 }
195
196 if score > bestScore {
197 bestScore = score
198 bestAttachmentIndex = currentAttachmentIndex
199 }
200 }
201
202 return bestAttachmentIndex
203}
204
205func (s *MediaService) ExtractThumbnail(id int64) (string, error) {
206 media, err := s.repo.GetByID(id)
207 if err != nil {
208 return "", err
209 }
210
211 if media.IsAudio {
212 return "", fmt.Errorf("audio files don't have thumbnails")
213 }
214
215 thumbPath := s.thumbnailPath(media.Filepath)
216
217 // Check if thumbnail already exists
218 if _, err := os.Stat(thumbPath); err == nil {
219 return thumbPath, nil
220 }
221
222 // Try to extract embedded image attachment
223 streamIndex := s.findImageAttachment(media.Filepath)
224 if streamIndex >= 0 {
225 // Create temp file for raw attachment
226 tempFile, err := os.CreateTemp("", "vidarchive-attachment-*")
227 if err != nil {
228 goto frameFallback
229 }
230 tempPath := tempFile.Name()
231 tempFile.Close()
232 defer os.Remove(tempPath)
233
234 // Extract attachment (-dump_attachment is an input option, must come before -i)
235 cmd := exec.Command("ffmpeg",
236 fmt.Sprintf("-dump_attachment:t:%d", streamIndex),
237 tempPath,
238 "-i", media.Filepath,
239 "-y",
240 "-t", "0",
241 "-f", "null", "-",
242 )
243 if err := cmd.Run(); err != nil {
244 goto frameFallback
245 }
246
247 // Convert to canonical WebP
248 cmd = exec.Command("ffmpeg",
249 "-i", tempPath,
250 thumbPath,
251 "-y",
252 )
253 if err := cmd.Run(); err != nil {
254 goto frameFallback
255 }
256
257 return thumbPath, nil
258 }
259
260frameFallback:
261
262 // Extract a video frame at 50% duration
263 // Get video duration
264 durationCmd := exec.Command("ffprobe",
265 "-v", "error",
266 "-show_entries", "format=duration",
267 "-of", "default=noprint_wrappers=1:nokey=1",
268 media.Filepath,
269 )
270 durationOut, durationErr := durationCmd.Output()
271
272 seekTime := "00:00:01"
273 if durationErr == nil {
274 if durationSec, parseErr := strconv.ParseFloat(strings.TrimSpace(string(durationOut)), 64); parseErr == nil && durationSec > 0 {
275 midpoint := durationSec / 2
276 // Format as HH:MM:SS or MM:SS
277 minutes := int(midpoint) / 60
278 seconds := int(midpoint) % 60
279 hours := minutes / 60
280 minutes = minutes % 60
281 if hours > 0 {
282 seekTime = fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
283 } else {
284 seekTime = fmt.Sprintf("%02d:%02d", minutes, seconds)
285 }
286 }
287 }
288
289 cmd := exec.Command("ffmpeg", "-ss", seekTime, "-i", media.Filepath, "-vframes", "1", thumbPath)
290 if err := cmd.Run(); err != nil {
291 return "", fmt.Errorf("ffmpeg failed: %w", err)
292 }
293
294 return thumbPath, nil
295}
296
297func (s *MediaService) HasThumbnail(id int64) bool {
298 media, err := s.repo.GetByID(id)
299 if err != nil {
300 return false
301 }
302
303 thumbPath := s.thumbnailPath(media.Filepath)
304 _, err = os.Stat(thumbPath)
305 return err == nil
306}
307
308type MediaMetadata struct {
309 Container string
310 VideoCodec string
311 AudioCodec string
312 Resolution string
313 Bitrate string
314 FileSize int64
315}
316
317type ffprobeOutput struct {
318 Format struct {
319 FormatName string `json:"format_name"`
320 BitRate string `json:"bit_rate"`
321 } `json:"format"`
322 Streams []struct {
323 CodecName string `json:"codec_name"`
324 Width int `json:"width"`
325 Height int `json:"height"`
326 CodecType string `json:"codec_type"`
327 } `json:"streams"`
328}
329
330func (s *MediaService) GetMetadata(id int64) (*MediaMetadata, error) {
331 media, err := s.repo.GetByID(id)
332 if err != nil {
333 return nil, err
334 }
335
336 // Get file size
337 info, err := os.Stat(media.Filepath)
338 if err != nil {
339 return nil, err
340 }
341
342 meta := &MediaMetadata{
343 FileSize: info.Size(),
344 }
345
346 // Get metadata via ffprobe JSON output
347 cmd := exec.Command("ffprobe",
348 "-v", "error",
349 "-show_format",
350 "-show_streams",
351 "-of", "json",
352 media.Filepath,
353 )
354 output, err := cmd.Output()
355 if err != nil {
356 return meta, nil // Return partial metadata (file size only)
357 }
358
359 var probe ffprobeOutput
360 if err := json.Unmarshal(output, &probe); err != nil {
361 return meta, nil
362 }
363
364 // Container format
365 if probe.Format.FormatName != "" {
366 parts := strings.Split(probe.Format.FormatName, ",")
367 meta.Container = parts[0]
368 }
369
370 // Overall bitrate
371 if probe.Format.BitRate != "" {
372 if br, err := strconv.ParseInt(probe.Format.BitRate, 10, 64); err == nil {
373 meta.Bitrate = fmt.Sprintf("%d", br/1000)
374 }
375 }
376
377 // Stream info
378 for _, stream := range probe.Streams {
379 switch stream.CodecType {
380 case "video":
381 meta.VideoCodec = stream.CodecName
382 if stream.Width > 0 && stream.Height > 0 {
383 meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
384 }
385 case "audio":
386 meta.AudioCodec = stream.CodecName
387 }
388 }
389
390 return meta, nil
391}
392
393func (s *MediaService) subtitleCachePath(id int64) string {
394 return filepath.Join(s.subtitleCacheDir, fmt.Sprintf("%d", id))
395}
396
397// GetSubtitlePath returns the full path to a cached subtitle file for a given media ID and language.
398func (s *MediaService) GetSubtitlePath(id int64, lang string) string {
399 return filepath.Join(s.subtitleCachePath(id), lang+".vtt")
400}
401
402// GetSubtitles extracts embedded text subtitles from a media file, converts them to VTT,
403// and returns track info for the HTML5 video player. Results are cached.
404func (s *MediaService) GetSubtitles(id int64) ([]models.SubtitleTrack, error) {
405 media, err := s.repo.GetByID(id)
406 if err != nil {
407 return nil, err
408 }
409
410 cacheDir := s.subtitleCachePath(id)
411
412 // Check cache first
413 entries, err := os.ReadDir(cacheDir)
414 if err == nil && len(entries) > 0 {
415 var tracks []models.SubtitleTrack
416 for _, entry := range entries {
417 if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" {
418 continue
419 }
420 lang := strings.TrimSuffix(entry.Name(), ".vtt")
421 label := lang
422 tracks = append(tracks, models.SubtitleTrack{
423 Lang: lang,
424 Label: label,
425 Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang),
426 })
427 }
428 return tracks, nil
429 }
430
431 // Extract subtitle streams
432 streams, err := s.extractSubtitleInfo(media.Filepath)
433 if err != nil || len(streams) == 0 {
434 return nil, err
435 }
436
437 if err := os.MkdirAll(cacheDir, 0755); err != nil {
438 return nil, err
439 }
440
441 var tracks []models.SubtitleTrack
442 for _, stream := range streams {
443 lang := stream.Lang
444 if lang == "" {
445 lang = fmt.Sprintf("track%d", stream.Index)
446 }
447
448 outPath := filepath.Join(cacheDir, lang+".vtt")
449 if err := s.extractSubtitleToVTT(media.Filepath, outPath, stream.Index); err != nil {
450 continue // Skip failed extractions
451 }
452
453 tracks = append(tracks, models.SubtitleTrack{
454 Lang: lang,
455 Label: stream.Label,
456 Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang),
457 })
458 }
459
460 return tracks, nil
461}
462
463type subtitleStream struct {
464 Index int
465 Lang string
466 Label string
467}
468
469// extractSubtitleInfo finds text-based subtitle streams in a media file using ffprobe.
470func (s *MediaService) extractSubtitleInfo(path string) ([]subtitleStream, error) {
471 cmd := exec.Command("ffprobe",
472 "-v", "error",
473 "-show_streams",
474 "-select_streams", "s",
475 "-of", "json",
476 path,
477 )
478 output, err := cmd.Output()
479 if err != nil {
480 return nil, err
481 }
482
483 var probe struct {
484 Streams []struct {
485 Index int `json:"index"`
486 CodecName string `json:"codec_name"`
487 Tags struct {
488 Language string `json:"language"`
489 Title string `json:"title"`
490 } `json:"tags"`
491 } `json:"streams"`
492 }
493
494 if err := json.Unmarshal(output, &probe); err != nil {
495 return nil, err
496 }
497
498 var streams []subtitleStream
499 subIndex := 0
500 for _, stream := range probe.Streams {
501 // Only text-based subtitles
502 switch stream.CodecName {
503 case "subrip", "ass", "ssa", "webvtt", "mov_text":
504 lang := stream.Tags.Language
505 if lang == "" {
506 lang = fmt.Sprintf("track%d", subIndex)
507 }
508
509 label := stream.Tags.Title
510 if label == "" {
511 label = strings.ToUpper(lang)
512 }
513
514 streams = append(streams, subtitleStream{
515 Index: subIndex,
516 Lang: lang,
517 Label: label,
518 })
519 subIndex++
520 }
521 }
522
523 return streams, nil
524}
525
526// extractSubtitleToVTT extracts a subtitle stream and converts it to WebVTT format.
527func (s *MediaService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
528 cmd := exec.Command("ffmpeg",
529 "-i", inputPath,
530 "-map", fmt.Sprintf("0:s:%d", streamIndex),
531 "-f", "webvtt",
532 outputPath,
533 "-y",
534 )
535 output, err := cmd.CombinedOutput()
536 if err != nil {
537 return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
538 }
539 return nil
540}
541