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