package service import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "vidarchive/internal/models" "vidarchive/internal/util" ) func (s *LibraryService) SubtitleDir(relPath string) string { itemDir, err := s.resolveItemDir(relPath) if err != nil { return "" } return filepath.Join(itemDir, subtitlesDirName) } // GetSubtitlePath returns the .vtt path for a language. It errors when the item // can't be resolved: joining onto an empty dir would yield a bare relative name // that the caller would then serve relative to the process working directory. func (s *LibraryService) GetSubtitlePath(relPath, lang string) (string, error) { dir := s.SubtitleDir(relPath) if dir == "" { return "", fmt.Errorf("resolve subtitle dir for %q", relPath) } return filepath.Join(dir, lang+".vtt"), nil } func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, error) { item, err := s.GetByRelPath(relPath) if err != nil { return nil, err } cacheDir := s.SubtitleDir(relPath) 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") tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: lang, Src: fmt.Sprintf("/media/item/%s/subtitles/%s", util.URLEncodePath(relPath), lang), }) } return tracks, nil } for _, mf := range item.MediaFiles { if mf.IsAudio { continue } streams, err := s.extractSubtitleInfo(mf.Filepath) if err != nil || len(streams) == 0 { continue } 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(mf.Filepath, outPath, stream.Index); err != nil { continue } tracks = append(tracks, models.SubtitleTrack{ Lang: lang, Label: stream.Label, Src: fmt.Sprintf("/media/item/%s/subtitles/%s", util.URLEncodePath(relPath), lang), }) } return tracks, nil } return nil, nil } type subtitleStream struct { Index int Lang string Label string } func (s *LibraryService) extractSubtitleInfo(path string) ([]subtitleStream, error) { cmd := exec.Command(s.ffprobePath, "-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 { 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 } func (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error { cmd := exec.Command(s.ffmpegPath, "-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 }