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