subtitles.go
⎇
Raw
1package service
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
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, 0755); err != nil {
67 return nil, err
68 }
69 var tracks []models.SubtitleTrack
70 for _, stream := range streams {
71 lang := stream.Lang
72 if lang == "" {
73 lang = fmt.Sprintf("track%d", stream.Index)
74 }
75 outPath := filepath.Join(cacheDir, lang+".vtt")
76 if err := s.extractSubtitleToVTT(mf.Filepath, outPath, stream.Index); err != nil {
77 continue
78 }
79 tracks = append(tracks, models.SubtitleTrack{
80 Lang: lang,
81 Label: stream.Label,
82 Src: fmt.Sprintf("/media/item/%s/subtitles/%s", util.URLEncodePath(relPath), lang),
83 })
84 }
85 return tracks, nil
86 }
87
88 return nil, nil
89}
90
91type subtitleStream struct {
92 Index int
93 Lang string
94 Label string
95}
96
97func (s *LibraryService) extractSubtitleInfo(path string) ([]subtitleStream, error) {
98 cmd := exec.Command(s.ffprobePath,
99 "-v", "error",
100 "-show_streams",
101 "-select_streams", "s",
102 "-of", "json",
103 path,
104 )
105 output, err := cmd.Output()
106 if err != nil {
107 return nil, err
108 }
109 var probe struct {
110 Streams []struct {
111 Index int `json:"index"`
112 CodecName string `json:"codec_name"`
113 Tags struct {
114 Language string `json:"language"`
115 Title string `json:"title"`
116 } `json:"tags"`
117 } `json:"streams"`
118 }
119 if err := json.Unmarshal(output, &probe); err != nil {
120 return nil, err
121 }
122 var streams []subtitleStream
123 subIndex := 0
124 for _, stream := range probe.Streams {
125 switch stream.CodecName {
126 case "subrip", "ass", "ssa", "webvtt", "mov_text":
127 lang := stream.Tags.Language
128 if lang == "" {
129 lang = fmt.Sprintf("track%d", subIndex)
130 }
131 label := stream.Tags.Title
132 if label == "" {
133 label = strings.ToUpper(lang)
134 }
135 streams = append(streams, subtitleStream{
136 Index: subIndex,
137 Lang: lang,
138 Label: label,
139 })
140 subIndex++
141 }
142 }
143 return streams, nil
144}
145
146func (s *LibraryService) extractSubtitleToVTT(inputPath, outputPath string, streamIndex int) error {
147 cmd := exec.Command(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