subtitles.go
⎇
Raw
1package service
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9
10 "vidarchive/internal/models"
11 "vidarchive/internal/util"
12)
13
14func (s *LibraryService) SubtitleDir(relPath string) string {
15 itemDir, err := s.resolveItemDir(relPath)
16 if err != nil {
17 return ""
18 }
19 return filepath.Join(itemDir, subtitlesDirName)
20}
21
22// GetSubtitlePath returns the .vtt path for a language. It errors when the item
23// can't be resolved: joining onto an empty dir would yield a bare relative name
24// that the caller would then serve relative to the process working directory.
25func (s *LibraryService) GetSubtitlePath(relPath, lang string) (string, error) {
26 dir := s.SubtitleDir(relPath)
27 if dir == "" {
28 return "", fmt.Errorf("resolve subtitle dir for %q", relPath)
29 }
30 return filepath.Join(dir, lang+".vtt"), nil
31}
32
33func (s *LibraryService) GetSubtitles(relPath string) ([]models.SubtitleTrack, error) {
34 item, err := s.GetByRelPath(relPath)
35 if err != nil {
36 return nil, err
37 }
38
39 cacheDir := s.SubtitleDir(relPath)
40 entries, err := os.ReadDir(cacheDir)
41 if err == nil && len(entries) > 0 {
42 var tracks []models.SubtitleTrack
43 for _, entry := range entries {
44 if entry.IsDir() || filepath.Ext(entry.Name()) != ".vtt" {
45 continue
46 }
47 lang := strings.TrimSuffix(entry.Name(), ".vtt")
48 tracks = append(tracks, models.SubtitleTrack{
49 Lang: lang,
50 Label: lang,
51 Src: fmt.Sprintf("/media/item/%s/subtitles/%s", util.URLEncodePath(relPath), lang),
52 })
53 }
54 return tracks, nil
55 }
56
57 for _, mf := range item.MediaFiles {
58 if mf.IsAudio {
59 continue
60 }
61 streams, err := s.extractSubtitleInfo(mf.Filepath)
62 if err != nil || len(streams) == 0 {
63 continue
64 }
65 if err := os.MkdirAll(cacheDir, 0755); err != nil {
66 return nil, err
67 }
68 var tracks []models.SubtitleTrack
69 for _, stream := range streams {
70 // extractSubtitleInfo always fills Lang (falling back to "trackN").
71 lang := stream.Lang
72 outPath := filepath.Join(cacheDir, lang+".vtt")
73 if err := s.extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil {
74 continue
75 }
76 tracks = append(tracks, models.SubtitleTrack{
77 Lang: lang,
78 Label: stream.Label,
79 Src: fmt.Sprintf("/media/item/%s/subtitles/%s", util.URLEncodePath(relPath), lang),
80 })
81 }
82 return tracks, nil
83 }
84
85 return nil, nil
86}
87
88type subtitleStream struct {
89 Index int
90 Lang string
91 Label string
92}
93
94func (s *LibraryService) extractSubtitleInfo(path string) ([]subtitleStream, error) {
95 probe, err := s.probeStreams(path)
96 if err != nil {
97 return nil, err
98 }
99 return selectSubtitleStreams(probe.Streams), nil
100}
101
102func selectSubtitleStreams(all []ffprobeStream) []subtitleStream {
103 var streams []subtitleStream
104 // Index is an ffmpeg "0:s:N" selector, so it must count every subtitle
105 // stream. Counting only the convertible ones would shift the selector past a
106 // bitmap track (PGS, DVD subs) and extract the wrong stream.
107 subIndex := 0
108 for _, stream := range all {
109 if stream.CodecType != "subtitle" {
110 continue
111 }
112 index := subIndex
113 subIndex++
114
115 switch stream.CodecName {
116 case "subrip", "ass", "ssa", "webvtt", "mov_text":
117 default:
118 // Bitmap subtitles need OCR to become WebVTT. Skip them.
119 continue
120 }
121 lang := stream.Tags.Language
122 if lang == "" {
123 lang = fmt.Sprintf("track%d", index)
124 }
125 label := stream.Tags.Title
126 if label == "" {
127 label = strings.ToUpper(lang)
128 }
129 streams = append(streams, subtitleStream{
130 Index: index,
131 Lang: lang,
132 Label: label,
133 })
134 }
135 return streams
136}
137
138func (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
139 cmd := exec.Command(s.ffmpegPath,
140 "-i", inputPath,
141 "-map", fmt.Sprintf("0:s:%d", streamIndex),
142 "-f", "webvtt",
143 outputPath,
144 "-y",
145 )
146 output, err := cmd.CombinedOutput()
147 if err != nil {
148 return fmt.Errorf("ffmpeg subtitle extraction failed: %w\nOutput: %s", err, string(output))
149 }
150 return nil
151}
152