Skip to content

Commit 3d84b49

Browse files
authored
feat: add YouTube subtitle download & embed support (Fixes #288) (#1430)
* feat(youtube): add support for video captions in youtubeDownload * feat(youtube): filter subtitles based on Items option in Extract method * feat(youtube): display available caption languages in printInfo * feat(youtube): add option to embed subtitles into video during download * refactor(downloader): use utils.ConvertXmlToSrt and remove redundant function * test(utils): add unit test for ConvertXMLToSRT function * feat(readme): update subtitle download options and embed feature for YouTube * refactor(downloader): rename ConvertXmlToSrt to ConvertXMLFileToSRT and update implementation * refactor(ffmpeg): enhance subtitle handling with ISO 639-2 mapping and codec selection * refactor(utils): simplify XML unmarshalling in ConvertXMLToSRT function
1 parent baece38 commit 3d84b49

File tree

8 files changed

+294
-11
lines changed

8 files changed

+294
-11
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,11 @@ $ lux -j "https://www.bilibili.com/video/av20203945"
571571
#### Subtitle:
572572

573573
```
574-
-C Download captions
574+
-C Download subtitles
575+
-C -items en,zh
576+
Download specific languages (YouTube only)
577+
-C -items en,zh -embed
578+
Embed subtitles into the video (YouTube only)
575579
```
576580

577581
#### Youku:

app/app.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ func New() *cli.App {
119119
Aliases: []string{"C"},
120120
Usage: "Download captions",
121121
},
122+
&cli.BoolFlag{
123+
Name: "embed-subtitle",
124+
Aliases: []string{"embed"},
125+
Usage: "Embed subtitles into the video (requires ffmpeg)",
126+
},
122127

123128
&cli.UintFlag{
124129
Name: "start",
@@ -311,6 +316,7 @@ func download(c *cli.Context, videoURL string) error {
311316
OutputName: c.String("output-name"),
312317
FileNameLength: int(c.Uint("file-name-length")),
313318
Caption: c.Bool("caption"),
319+
EmbedSubtitle: c.Bool("embed-subtitle"),
314320
MultiThread: c.Bool("multi-thread"),
315321
ThreadNumber: int(c.Uint("thread")),
316322
RetryTimes: int(c.Uint("retry")),

downloader/downloader.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"path/filepath"
1313
"regexp"
1414
"sort"
15+
"strings"
1516
"sync"
1617
"time"
1718

@@ -34,6 +35,7 @@ type Options struct {
3435
OutputName string
3536
FileNameLength int
3637
Caption bool
38+
EmbedSubtitle bool
3739

3840
MultiThread bool
3941
ThreadNumber int
@@ -605,12 +607,28 @@ func (downloader *Downloader) Download(data *extractors.Data) error {
605607
}
606608

607609
// download caption
610+
var subtitlePaths []string
611+
var subtitleLangs []string
612+
var subtitleFilesToDelete []string
608613
if downloader.option.Caption && data.Captions != nil {
609614
fmt.Println("\nDownloading captions...")
610615
for k, v := range data.Captions {
611616
if v != nil {
612617
fmt.Printf("Downloading %s ...\n", k)
613-
downloader.caption(v.URL, title, v.Ext, v.Transform) // nolint
618+
if err := downloader.caption(v.URL, title, v.Ext, v.Transform); err != nil {
619+
// nolint
620+
} else if downloader.option.EmbedSubtitle {
621+
subtitlePath, _ := utils.FilePath(title, v.Ext, downloader.option.FileNameLength, downloader.option.OutputPath, true)
622+
subtitleFilesToDelete = append(subtitleFilesToDelete, subtitlePath)
623+
if strings.HasSuffix(v.Ext, "xml") {
624+
if srtPath, err := utils.ConvertXMLFileToSRT(subtitlePath); err == nil {
625+
subtitlePath = srtPath
626+
subtitleFilesToDelete = append(subtitleFilesToDelete, srtPath)
627+
}
628+
}
629+
subtitlePaths = append(subtitlePaths, subtitlePath)
630+
subtitleLangs = append(subtitleLangs, k)
631+
}
614632
}
615633
}
616634
}
@@ -652,6 +670,18 @@ func (downloader *Downloader) Download(data *extractors.Data) error {
652670
return err
653671
}
654672
downloader.bar.Finish()
673+
674+
if downloader.option.EmbedSubtitle && len(subtitlePaths) > 0 {
675+
if !downloader.option.Silent {
676+
fmt.Println("Embedding subtitles...")
677+
}
678+
if err := utils.EmbedSubtitles(mergedFilePath, subtitlePaths, subtitleLangs); err != nil {
679+
return err
680+
}
681+
for _, path := range subtitleFilesToDelete {
682+
os.Remove(path)
683+
}
684+
}
655685
return nil
656686
}
657687

@@ -706,7 +736,26 @@ func (downloader *Downloader) Download(data *extractors.Data) error {
706736
fmt.Printf("Merging video parts into %s\n", mergedFilePath)
707737
}
708738
if stream.Ext != "mp4" || stream.NeedMux {
709-
return utils.MergeFilesWithSameExtension(parts, mergedFilePath)
739+
if err := utils.MergeFilesWithSameExtension(parts, mergedFilePath); err != nil {
740+
return err
741+
}
742+
} else {
743+
if err := utils.MergeToMP4(parts, mergedFilePath, title); err != nil {
744+
return err
745+
}
746+
}
747+
748+
if downloader.option.EmbedSubtitle && len(subtitlePaths) > 0 {
749+
if !downloader.option.Silent {
750+
fmt.Println("Embedding subtitles...")
751+
}
752+
if err := utils.EmbedSubtitles(mergedFilePath, subtitlePaths, subtitleLangs); err != nil {
753+
return err
754+
}
755+
for _, path := range subtitleFilesToDelete {
756+
os.Remove(path)
757+
}
710758
}
711-
return utils.MergeToMP4(parts, mergedFilePath, title)
759+
760+
return nil
712761
}

downloader/utils.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,23 @@ func printStream(stream *extractors.Stream) {
5151

5252
func printInfo(data *extractors.Data, sortedStreams []*extractors.Stream) {
5353
printHeader(data)
54-
54+
if len(data.Captions) > 0 {
55+
cyan.Printf(" Captions: ") // nolint
56+
languages := make([]string, 0, len(data.Captions))
57+
for lang := range data.Captions {
58+
languages = append(languages, lang)
59+
}
60+
sort.Strings(languages)
61+
captionList := ""
62+
for _, lang := range languages {
63+
caption := data.Captions[lang]
64+
if caption == nil {
65+
continue
66+
}
67+
captionList += fmt.Sprintf("%s ", lang)
68+
}
69+
fmt.Println(captionList)
70+
}
5571
cyan.Printf(" Streams: ") // nolint
5672
fmt.Println("# All available quality")
5773
for _, stream := range sortedStreams {

extractors/youtube/youtube.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"slices"
77
"strconv"
8+
"strings"
89

910
"github.com/kkdai/youtube/v2"
1011
"github.com/pkg/errors"
@@ -46,7 +47,19 @@ func (e *extractor) Extract(url string, option extractors.Options) ([]*extractor
4647
if err != nil {
4748
return nil, errors.WithStack(err)
4849
}
49-
return []*extractors.Data{e.youtubeDownload(url, video)}, nil
50+
data := e.youtubeDownload(url, video)
51+
if option.Items != "" {
52+
// If it is not a playlist, we can use the Items option to filter the subtitles.
53+
filteredCaptions := make(map[string]*extractors.CaptionPart)
54+
items := strings.Split(option.Items, ",")
55+
for k, v := range data.Captions {
56+
if slices.Contains(items, k) {
57+
filteredCaptions[k] = v
58+
}
59+
}
60+
data.Captions = filteredCaptions
61+
}
62+
return []*extractors.Data{data}, nil
5063
}
5164

5265
playlist, err := e.client.GetPlaylist(url)
@@ -127,12 +140,23 @@ func (e *extractor) youtubeDownload(url string, video *youtube.Video) *extractor
127140
streams[itag] = stream
128141
}
129142

143+
captions := make(map[string]*extractors.CaptionPart)
144+
for _, c := range video.CaptionTracks {
145+
captions[c.LanguageCode] = &extractors.CaptionPart{
146+
Part: extractors.Part{
147+
URL: c.BaseURL,
148+
Ext: c.LanguageCode + ".xml",
149+
},
150+
}
151+
}
152+
130153
return &extractors.Data{
131-
Site: "YouTube youtube.com",
132-
Title: video.Title,
133-
Type: "video",
134-
Streams: streams,
135-
URL: url,
154+
Site: "YouTube youtube.com",
155+
Title: video.Title,
156+
Type: "video",
157+
Streams: streams,
158+
Captions: captions,
159+
URL: url,
136160
}
137161
}
138162

utils/ffmpeg.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os/exec"
88
"path/filepath"
99
"runtime"
10+
"strings"
1011

1112
"github.com/pkg/errors"
1213
)
@@ -76,3 +77,81 @@ func MergeToMP4(paths []string, mergedFilePath string, filename string) error {
7677
)
7778
return runMergeCmd(cmd, paths, mergeFilePath)
7879
}
80+
81+
// ISO 639-2 language code mapping
82+
var langToISO = map[string]string{
83+
"zh": "chi", "en": "eng", "ja": "jpn", "ko": "kor",
84+
"es": "spa", "fr": "fre", "de": "ger", "ru": "rus",
85+
"pt": "por", "it": "ita", "nl": "nld", "sv": "swe",
86+
"no": "nor", "fi": "fin", "da": "dan", "pl": "pol",
87+
}
88+
89+
// toISO639 converts language code (e.g. "en-US") to ISO 639-2 (e.g. "eng")
90+
func toISO639(lang string) string {
91+
base := strings.ToLower(lang)
92+
if i := strings.IndexAny(base, "-_"); i != -1 {
93+
base = base[:i]
94+
}
95+
if iso, ok := langToISO[base]; ok {
96+
return iso
97+
}
98+
if len(base) == 3 {
99+
return base
100+
}
101+
return "und"
102+
}
103+
104+
// subtitleCodec returns the appropriate subtitle codec for the container format
105+
func subtitleCodec(ext string) string {
106+
switch strings.ToLower(ext) {
107+
case ".mp4":
108+
return "mov_text"
109+
case ".webm":
110+
return "webvtt"
111+
default:
112+
return ""
113+
}
114+
}
115+
116+
// EmbedSubtitles embeds subtitles into the video.
117+
func EmbedSubtitles(videoPath string, subtitlePaths []string, langs []string) error {
118+
ext := filepath.Ext(videoPath)
119+
tempOutput := videoPath + ".temp" + ext
120+
121+
// Build ffmpeg command
122+
cmds := []string{"-y", "-i", videoPath}
123+
for _, subPath := range subtitlePaths {
124+
cmds = append(cmds, "-i", subPath)
125+
}
126+
127+
cmds = append(cmds,
128+
"-map", "0", "-dn", "-ignore_unknown",
129+
"-c", "copy",
130+
)
131+
132+
if codec := subtitleCodec(ext); codec != "" {
133+
cmds = append(cmds, "-c:s", codec)
134+
}
135+
136+
// Exclude existing subtitles, then map new ones
137+
cmds = append(cmds, "-map", "-0:s")
138+
for i, lang := range langs {
139+
if i >= len(subtitlePaths) {
140+
break
141+
}
142+
iso := toISO639(lang)
143+
cmds = append(cmds,
144+
"-map", fmt.Sprintf("%d:0", i+1),
145+
fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("language=%s", iso),
146+
fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("handler_name=%s", lang),
147+
fmt.Sprintf("-metadata:s:s:%d", i), fmt.Sprintf("title=%s", lang),
148+
)
149+
}
150+
151+
cmds = append(cmds, tempOutput)
152+
153+
if err := runMergeCmd(exec.Command(findFFmpegExecutable(), cmds...), []string{videoPath}, ""); err != nil {
154+
return err
155+
}
156+
return os.Rename(tempOutput, videoPath)
157+
}

utils/utils.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"crypto/md5"
7+
"encoding/xml"
78
"fmt"
89
"io"
910
"net/url"
@@ -19,6 +20,78 @@ import (
1920
"github.com/iawia002/lux/request"
2021
)
2122

23+
// ConvertXMLToSRT converts YouTube XML subtitles to SRT format
24+
func ConvertXMLToSRT(xmlContent []byte) (string, error) {
25+
var data struct {
26+
Body struct {
27+
P []struct {
28+
T int `xml:"t,attr"`
29+
D int `xml:"d,attr"`
30+
Text string `xml:",chardata"`
31+
S []struct {
32+
T int `xml:"t,attr"`
33+
Text string `xml:",chardata"`
34+
} `xml:"s"`
35+
} `xml:"p"`
36+
} `xml:"body"`
37+
}
38+
39+
if err := xml.Unmarshal(xmlContent, &data); err != nil {
40+
return "", err
41+
}
42+
43+
var srtBuilder strings.Builder
44+
index := 1
45+
for _, p := range data.Body.P {
46+
startTime := formatSRTTime(p.T)
47+
endTime := formatSRTTime(p.T + p.D)
48+
49+
// Handle text content
50+
var text string
51+
if len(p.S) > 0 {
52+
for _, s := range p.S {
53+
text += s.Text
54+
}
55+
} else {
56+
text = p.Text
57+
}
58+
text = strings.TrimSpace(text)
59+
60+
// Skip empty lines
61+
if text == "" {
62+
continue
63+
}
64+
65+
srtBuilder.WriteString(fmt.Sprintf("%d\n%s --> %s\n%s\n\n", index, startTime, endTime, text))
66+
index++
67+
}
68+
return srtBuilder.String(), nil
69+
}
70+
71+
// ConvertXMLFileToSRT converts XML subtitles file to SRT format
72+
func ConvertXMLFileToSRT(xmlPath string) (string, error) {
73+
content, err := os.ReadFile(xmlPath)
74+
if err != nil {
75+
return "", err
76+
}
77+
srtContent, err := ConvertXMLToSRT(content)
78+
if err != nil {
79+
return "", err
80+
}
81+
srtPath := xmlPath[:len(xmlPath)-len("xml")] + "srt"
82+
return srtPath, os.WriteFile(srtPath, []byte(srtContent), 0644)
83+
}
84+
85+
func formatSRTTime(ms int) string {
86+
hours := ms / 3600000
87+
ms %= 3600000
88+
minutes := ms / 60000
89+
ms %= 60000
90+
seconds := ms / 1000
91+
ms %= 1000
92+
return fmt.Sprintf("%02d:%02d:%02d,%03d", hours, minutes, seconds, ms)
93+
}
94+
2295
// MatchOneOf match one of the patterns
2396
func MatchOneOf(text string, patterns ...string) []string {
2497
var (

0 commit comments

Comments
 (0)