package service import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "vidarchive/internal/models" "vidarchive/internal/repository" ) type MediaService struct { repo *repository.MediaRepository downloadDir string subtitleCacheDir string } func NewMediaService(repo *repository.MediaRepository, downloadDir, subtitleCacheDir string) *MediaService { return &MediaService{repo: repo, downloadDir: downloadDir, subtitleCacheDir: subtitleCacheDir} } func (s *MediaService) GetByID(id int64) (*models.Media, error) { return s.repo.GetByID(id) } func (s *MediaService) GetByRelativePath(relPath string) (*models.Media, error) { return s.repo.GetByRelativePath(relPath) } func (s *MediaService) GetAll(path, sortBy, filter string) ([]*models.Media, []string, error) { if path != "" && !strings.HasSuffix(path, "/") { path += "/" } media, err := s.repo.GetAll(path, sortBy, filter) if err != nil { return nil, nil, err } folders, err := s.repo.GetFolders(path) if err != nil { return nil, nil, err } return media, folders, nil } func (s *MediaService) ScanLibrary() error { return filepath.Walk(s.downloadDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } // Check if this file is already in DB relPath, _ := filepath.Rel(s.downloadDir, path) relPath = filepath.ToSlash(relPath) _, err = s.repo.GetByRelativePath(relPath) if err == nil { return nil // already exists } // Determine file type ext := strings.ToLower(filepath.Ext(path)) isAudio := ext == ".mp3" || ext == ".wav" || ext == ".flac" || ext == ".aac" || ext == ".opus" isVideo := ext == ".mp4" || ext == ".webm" || ext == ".mkv" || ext == ".avi" || ext == ".mov" if !isAudio && !isVideo { return nil } // Try to find info.json basePath := strings.TrimSuffix(path, ext) infoJSONPath := basePath + ".info.json" if _, err := os.Stat(infoJSONPath); err != nil { infoJSONPath = "" } media := &models.Media{ Filepath: path, RelativePath: relPath, IsAudio: isAudio, InfoJSONPath: infoJSONPath, } // Try to extract title from filename media.Title = strings.TrimSuffix(filepath.Base(path), ext) return s.repo.Create(media) }) } func (s *MediaService) Delete(id int64) error { media, err := s.repo.GetByID(id) if err != nil { return err } // Delete file if err := os.Remove(media.Filepath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove file: %w", err) } // Delete info json if exists if media.InfoJSONPath != "" { os.Remove(media.InfoJSONPath) } // Delete thumbnail if exists thumbPath := s.thumbnailPath(media.Filepath) os.Remove(thumbPath) // Delete subtitle cache if exists os.RemoveAll(s.subtitleCachePath(id)) return s.repo.Delete(id) } func (s *MediaService) thumbnailPath(path string) string { ext := filepath.Ext(path) return strings.TrimSuffix(path, ext) + ".webp" } // ffprobeAttachmentStream represents an attachment stream from ffprobe output type ffprobeAttachmentStream struct { Index int `json:"index"` CodecType string `json:"codec_type"` Tags struct { Mimetype string `json:"mimetype"` Filename string `json:"filename"` } `json:"tags"` } type ffprobeAttachmentOutput struct { Streams []ffprobeAttachmentStream `json:"streams"` } // findImageAttachment uses ffprobe to find the best image attachment in a media file. // It returns the attachment index (0-based among all attachment streams), or -1 if none found. func (s *MediaService) findImageAttachment(path string) int { cmd := exec.Command("ffprobe", "-v", "error", "-show_streams", "-of", "json", path, ) output, err := cmd.Output() if err != nil { return -1 } var probe ffprobeAttachmentOutput if err := json.Unmarshal(output, &probe); err != nil { return -1 } bestAttachmentIndex := -1 bestScore := 0 attachmentCount := 0 for _, stream := range probe.Streams { // Only consider attachment streams with image/* mimetype if stream.CodecType != "attachment" { continue } currentAttachmentIndex := attachmentCount attachmentCount++ if !strings.HasPrefix(strings.ToLower(stream.Tags.Mimetype), "image/") { continue } score := 0 filenameLower := strings.ToLower(stream.Tags.Filename) switch { case strings.Contains(filenameLower, "cover"): score = 100 case strings.Contains(filenameLower, "thumbnail"), strings.Contains(filenameLower, "thumb"): score = 80 case strings.Contains(filenameLower, "poster"): score = 60 case strings.Contains(filenameLower, "art"): score = 40 default: score = 20 } if score > bestScore { bestScore = score bestAttachmentIndex = currentAttachmentIndex } } return bestAttachmentIndex } func (s *MediaService) ExtractThumbnail(id int64) (string, error) { media, err := s.repo.GetByID(id) if err != nil { return "", err } if media.IsAudio { return "", fmt.Errorf("audio files don't have thumbnails") } thumbPath := s.thumbnailPath(media.Filepath) // Check if thumbnail already exists if _, err := os.Stat(thumbPath); err == nil { return thumbPath, nil } // Try to extract embedded image attachment streamIndex := s.findImageAttachment(media.Filepath) if streamIndex >= 0 { // Create temp file for raw attachment tempFile, err := os.CreateTemp("", "vidarchive-attachment-*") if err != nil { goto frameFallback } tempPath := tempFile.Name() tempFile.Close() defer os.Remove(tempPath) // Extract attachment (-dump_attachment is an input option, must come before -i) cmd := exec.Command("ffmpeg", fmt.Sprintf("-dump_attachment:t:%d", streamIndex), tempPath, "-i", media.Filepath, "-y", "-t", "0", "-f", "null", "-", ) if err := cmd.Run(); err != nil { goto frameFallback } // Convert to canonical WebP cmd = exec.Command("ffmpeg", "-i", tempPath, thumbPath, "-y", ) if err := cmd.Run(); err != nil { goto frameFallback } return thumbPath, nil } frameFallback: // Extract a video frame at 50% duration // Get video duration durationCmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", media.Filepath, ) durationOut, durationErr := durationCmd.Output() seekTime := "00:00:01" if durationErr == nil { if durationSec, parseErr := strconv.ParseFloat(strings.TrimSpace(string(durationOut)), 64); parseErr == nil && durationSec > 0 { midpoint := durationSec / 2 // Format as HH:MM:SS or MM:SS minutes := int(midpoint) / 60 seconds := int(midpoint) % 60 hours := minutes / 60 minutes = minutes % 60 if hours > 0 { seekTime = fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) } else { seekTime = fmt.Sprintf("%02d:%02d", minutes, seconds) } } } cmd := exec.Command("ffmpeg", "-ss", seekTime, "-i", media.Filepath, "-vframes", "1", thumbPath) if err := cmd.Run(); err != nil { return "", fmt.Errorf("ffmpeg failed: %w", err) } return thumbPath, nil } func (s *MediaService) HasThumbnail(id int64) bool { media, err := s.repo.GetByID(id) if err != nil { return false } thumbPath := s.thumbnailPath(media.Filepath) _, err = os.Stat(thumbPath) return err == nil } type MediaMetadata struct { Container string VideoCodec string AudioCodec string Resolution string Bitrate string FileSize int64 } type ffprobeOutput struct { Format struct { FormatName string `json:"format_name"` BitRate string `json:"bit_rate"` } `json:"format"` Streams []struct { CodecName string `json:"codec_name"` Width int `json:"width"` Height int `json:"height"` CodecType string `json:"codec_type"` } `json:"streams"` } func (s *MediaService) GetMetadata(id int64) (*MediaMetadata, error) { media, err := s.repo.GetByID(id) if err != nil { return nil, err } // Get file size info, err := os.Stat(media.Filepath) if err != nil { return nil, err } meta := &MediaMetadata{ FileSize: info.Size(), } // Get metadata via ffprobe JSON output cmd := exec.Command("ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", media.Filepath, ) output, err := cmd.Output() if err != nil { return meta, nil // Return partial metadata (file size only) } var probe ffprobeOutput if err := json.Unmarshal(output, &probe); err != nil { return meta, nil } // Container format if probe.Format.FormatName != "" { parts := strings.Split(probe.Format.FormatName, ",") meta.Container = parts[0] } // Overall bitrate if probe.Format.BitRate != "" { if br, err := strconv.ParseInt(probe.Format.BitRate, 10, 64); err == nil { meta.Bitrate = fmt.Sprintf("%d", br/1000) } } // Stream info for _, stream := range probe.Streams { switch stream.CodecType { case "video": meta.VideoCodec = stream.CodecName if stream.Width > 0 && stream.Height > 0 { meta.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height) } case "audio": meta.AudioCodec = stream.CodecName } } return meta, nil } func (s *MediaService) subtitleCachePath(id int64) string { return filepath.Join(s.subtitleCacheDir, fmt.Sprintf("%d", id)) } // GetSubtitlePath returns the full path to a cached subtitle file for a given media ID and language. func (s *MediaService) GetSubtitlePath(id int64, lang string) string { return filepath.Join(s.subtitleCachePath(id), lang+".vtt") } // GetSubtitles extracts embedded text subtitles from a media file, converts them to VTT, // and returns track info for the HTML5 video player. Results are cached. func (s *MediaService) GetSubtitles(id int64) ([]models.SubtitleTrack, error) { media, err := s.repo.GetByID(id) if err != nil { return nil, err } cacheDir := s.subtitleCachePath(id) // Check cache first entries, err := os.ReadDir(cacheDir) if err == nil && len(entries) > 0 { var tracks []models.SubtitleTrack for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" { continue } lang := strings.TrimSuffix(entry.Name(), ".vtt") label := lang tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: label, Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang), }) } return tracks, nil } // Extract subtitle streams streams, err := s.extractSubtitleInfo(media.Filepath) if err != nil || len(streams) == 0 { return nil, err } if err := os.MkdirAll(cacheDir, 0755); err != nil { return nil, err } var tracks []models.SubtitleTrack for _, stream := range streams { lang := stream.Lang if lang == "" { lang = fmt.Sprintf("track%d", stream.Index) } outPath := filepath.Join(cacheDir, lang+".vtt") if err := s.extractSubtitleToVTT(media.Filepath, outPath, stream.Index); err != nil { continue // Skip failed extractions } tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: stream.Label, Src: fmt.Sprintf("/media/%d/subtitles/%s", id, lang), }) } return tracks, nil } type subtitleStream struct { Index int Lang string Label string } // extractSubtitleInfo finds text-based subtitle streams in a media file using ffprobe. func (s *MediaService) extractSubtitleInfo(path string) ([]subtitleStream, error) { cmd := exec.Command("ffprobe", "-v", "error", "-show_streams", "-select_streams", "s", "-of", "json", path, ) output, err := cmd.Output() if err != nil { return nil, err } var probe struct { Streams []struct { Index int `json:"index"` CodecName string `json:"codec_name"` Tags struct { Language string `json:"language"` Title string `json:"title"` } `json:"tags"` } `json:"streams"` } if err := json.Unmarshal(output, &probe); err != nil { return nil, err } var streams []subtitleStream subIndex := 0 for _, stream := range probe.Streams { // Only text-based subtitles switch stream.CodecName { case "subrip", "ass", "ssa", "webvtt", "mov_text": lang := stream.Tags.Language if lang == "" { lang = fmt.Sprintf("track%d", subIndex) } label := stream.Tags.Title if label == "" { label = strings.ToUpper(lang) } streams = append(streams, subtitleStream{ Index: subIndex, Lang: lang, Label: label, }) subIndex++ } } return streams, nil } // extractSubtitleToVTT extracts a subtitle stream and converts it to WebVTT format. func (s *MediaService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error { cmd := exec.Command("ffmpeg", "-i", inputPath, "-map", fmt.Sprintf("0:s:%d", streamIndex), "-f", "webvtt", outputPath, "-y", ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output)) } return nil }