package service import ( "context" "fmt" "os" "path/filepath" "strings" "time" "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, 0o755); err != nil { return nil, err } var tracks []models.SubtitleTrack for _, stream := range streams { // extractSubtitleInfo always fills Lang (falling back to "trackN"). lang := stream.Lang 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) { probe, err := s.probeStreams(path) if err != nil { return nil, err } return selectSubtitleStreams(probe.Streams), nil } func selectSubtitleStreams(all []ffprobeStream) []subtitleStream { var streams []subtitleStream // Index is an ffmpeg "0:s:N" selector, so it must count every subtitle // stream. Counting only the convertible ones would shift the selector past a // bitmap track (PGS, DVD subs) and extract the wrong stream. subIndex := 0 for _, stream := range all { if stream.CodecType != "subtitle" { continue } index := subIndex subIndex++ switch stream.CodecName { case "subrip", "ass", "ssa", "webvtt", "mov_text": default: // Bitmap subtitles need OCR to become WebVTT. Skip them. continue } lang := stream.Tags.Language if lang == "" { lang = fmt.Sprintf("track%d", index) } label := stream.Tags.Title if label == "" { label = strings.ToUpper(lang) } streams = append(streams, subtitleStream{ Index: index, Lang: lang, Label: label, }) } return streams } // A subtitle track is small, but ffmpeg still walks the whole container to // find it, so allow more than a probe and less than a download. const subtitleExtractTimeout = 5 * time.Minute func (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error { ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout) defer cancel() cmd := util.KillableCommand(ctx, 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 }