From 43107bf5c16fbdd8e8cc8670e1eaa2385d5f89f0 Mon Sep 17 00:00:00 2001 From: Hami Lemon Date: Fri, 18 Feb 2022 15:23:24 +0800 Subject: [PATCH] initial commit --- .gitignore | 92 ++++++ .idea/.gitignore | 8 + .idea/inspectionProfiles/Project_Default.xml | 20 ++ .idea/lrc2srt.iml | 11 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + cloudlyric.go | 60 ++++ go.mod | 7 + go.sum | 4 + lts.go | 329 +++++++++++++++++++ qqlyric.go | 62 ++++ util.go | 60 ++++ 12 files changed, 667 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/lrc2srt.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 cloudlyric.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lts.go create mode 100644 qqlyric.go create mode 100644 util.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6d8712 --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Go template +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +*.bat +/temp diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ee950a9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/lrc2srt.iml b/.idea/lrc2srt.iml new file mode 100644 index 0000000..a41de17 --- /dev/null +++ b/.idea/lrc2srt.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..19b0b7d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/cloudlyric.go b/cloudlyric.go new file mode 100644 index 0000000..baac27a --- /dev/null +++ b/cloudlyric.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" +) + +type CloudLyricBase struct { + Version int `json:"version"` + Lyric string `json:"lyric"` +} + +type CloudLyric struct { + Sgc bool `json:"sgc"` + Sfy bool `json:"sfy"` + Qfy bool `json:"qfy"` + TransUser interface{} `json:"transUser"` + Lrc CloudLyricBase `json:"lrc"` + TLyric CloudLyricBase `json:"tlyric"` + Code int `json:"code"` +} + +func Get163Lyric(id string) (lyric, tLyric string) { + api := "https://music.163.com/api/song/lyric" + params := url.Values{} + params.Add("os", "pc") + //歌曲的id号 + params.Add("id", id) + //包含原始歌词 + params.Add("lv", "1") + //包含翻译歌词 + params.Add("tv", "1") + api = fmt.Sprintf("%s?%s", api, params.Encode()) + + req, _ := http.NewRequest("GET", api, nil) + //必须设置Referer,否则会请求失败 + req.Header.Add("Referer", "https://music.163.com") + req.Header.Add("User-Agent", ChromeUA) + resp, err := client.Do(req) + if err != nil { + fmt.Printf("网络错误:%v\n", err) + os.Exit(1) + } + if resp == nil || resp.StatusCode != http.StatusOK { + fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode) + os.Exit(1) + } + defer resp.Body.Close() + + var cloudLyric CloudLyric + err = json.NewDecoder(resp.Body).Decode(&cloudLyric) + if cloudLyric.Sgc { + fmt.Printf("获取歌词失败,返回的结果为:%+v,请检查id是否正确\n", cloudLyric) + os.Exit(1) + } + return cloudLyric.Lrc.Lyric, cloudLyric.TLyric.Lyric +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..77c5a2a --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/hami_lemon/lrc2srt + +go 1.17 + +require github.com/jessevdk/go-flags v1.5.0 + +require golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df31363 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 h1:EZ2mChiOa8udjfp6rRmswTbtZN/QzUQp4ptM4rnjHvc= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/lts.go b/lts.go new file mode 100644 index 0000000..59de013 --- /dev/null +++ b/lts.go @@ -0,0 +1,329 @@ +package main + +import ( + "bufio" + "fmt" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jessevdk/go-flags" +) + +type SRTContent struct { + //序号,从1开始 + Index int + //开始时间,单位毫秒 + Start int + //结束时间,单位毫秒 + End int + //歌词内容 + Text string +} +type SRT struct { + //歌曲名 + Title string + //歌手名 未指定文件名是,文件名格式为:歌曲名-歌手名.srt + Artist string + Content []*SRTContent +} + +// Option 运行时传入的选项 +type Option struct { + Id string `short:"i" long:"id" description:"歌曲的id,网易云和QQ音乐均可。"` + Input string `short:"I" long:"input" description:"需要转换的LRC文件路径。"` + Source string `short:"s" long:"source" description:"当设置id时有效,指定从网易云(163)还是QQ音乐(qq)上获取歌词。" default:"163" choice:"163" choice:"qq" choice:"QQ"` + Download bool `short:"d" long:"download" description:"只下载歌词,而不进行解析。"` + Mode int `short:"m" long:"mode" default:"1" description:"原文和译文的排列模式,可选值有:[1] [2] [3]" choice:"1" choice:"2" choice:"3"` + Version bool `short:"v" long:"version" description:"获取版本信息"` + Output string `no-flag:""` +} + +const ( + // VERSION 当前版本 + VERSION = `"0.1.0" (build 2022.02.18)` +) + +var ( + ChromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36" + client = http.Client{} + opt Option +) + +func main() { + args, err := flags.Parse(&opt) + if err != nil { + os.Exit(0) + } + //显示版本信息 + if opt.Version { + fmt.Printf("LrcToSrt(lts) version %s\n", VERSION) + os.Exit(0) + } + //获取保存的文件名 + if len(args) != 0 { + opt.Output = args[0] + } + + //获取歌词 + var lyric, tranLyric string + if opt.Id != "" { + if opt.Source != "163" { + lyric, tranLyric = GetQQLyric(opt.Id) + } else { + lyric, tranLyric = Get163Lyric(opt.Id) + } + //下载歌词 + if opt.Download { + //对文件名进行处理 + o := opt.Output + if o == "" { + o = opt.Id + ".lrc" + } else if !strings.HasSuffix(o, ".lrc") { + o += ".lrc" + } + WriteFile(o, lyric) + if tranLyric != "" { + WriteFile("tran_"+o, tranLyric) + } + fmt.Println("下载歌词完成!") + return + } + } else if opt.Input != "" { + //从文件中获取歌词 + if !strings.HasSuffix(opt.Input, ".lrc") { + fmt.Println("Error: 不支持的格式,目前只支持lrc歌词文件。") + os.Exit(1) + } + lyric = ReadFile(opt.Input) + if lyric == "" { + fmt.Println("获取歌词失败,文件内容为空。") + os.Exit(1) + } + } else { + fmt.Println("Error: 请指定需要转换的歌词。") + os.Exit(1) + } + + lyricSRT, tranLyricSRT := Lrc2Srt(lyric), Lrc2Srt(tranLyric) + SaveSRT(lyricSRT, tranLyricSRT, opt.Output) +} + +// SaveSRT 保存数据为SRT文件 +func SaveSRT(srt *SRT, tranSrt *SRT, name string) { + if tranSrt == nil { + //没有译文时,用一个空的对象的代替,减少nil判断 + tranSrt = &SRT{Content: make([]*SRTContent, 0)} + } + //处理结果文件的文件名 + if name == "" { + title := srt.Title + if title != "" { + //以歌曲名命名 + name = fmt.Sprintf("%s.srt", title) + } else if opt.Id != "" { + //以歌曲的id命名 + name = fmt.Sprintf("%s.srt", opt.Id) + } else if opt.Input != "" { + //以LRC文件的文件名命名 + name = fmt.Sprintf("%s.srt", opt.Input[:len(opt.Input)-4]) + } else { + //以当前时间的毫秒值命名 + name = fmt.Sprintf("%d.srt", time.Now().Unix()) + } + } else if !strings.HasSuffix(name, ".srt") { + name += ".srt" + } + + file, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE, os.ModePerm) + if err != nil { + fmt.Printf("保存结果失败:%v\n", err) + os.Exit(1) + } + defer file.Close() + writer := bufio.NewWriter(file) + + index := 1 + switch opt.Mode { + case 1: + //译文和原文交错排列 + _len, _lent, size := len(srt.Content), len(tranSrt.Content), 0 + if _len > _lent { + size = 2 * _len + } else { + size = 2 * _lent + } + //临时缓冲区,偶数索引存原文,奇数存译文 + buf := make([]*SRTContent, size, size) + for i, item := range srt.Content { + buf[2*i] = item + } + for i, item := range tranSrt.Content { + buf[2*i+1] = item + } + + //写入文件 + for _, item := range buf { + if item != nil { + item.Index = index + _, _ = writer.WriteString(item.String()) + index++ + } + } + case 2: + //原文在上,译文在下 + for _, item := range srt.Content { + item.Index = index + _, _ = writer.WriteString(item.String()) + index++ + } + + for _, item := range tranSrt.Content { + item.Index = index + _, _ = writer.WriteString(item.String()) + index++ + } + case 3: + //译文在上,原文在下 + for _, item := range tranSrt.Content { + item.Index = index + _, _ = writer.WriteString(item.String()) + index++ + } + for _, item := range srt.Content { + item.Index = index + _, _ = writer.WriteString(item.String()) + index++ + } + } + err = writer.Flush() + if err != nil { + fmt.Printf("保存结果失败:%v\n", err) + os.Exit(1) + } + fmt.Printf("转换文件完成,保存结果为:%s\n", name) +} + +// Lrc2Srt 将原始个LRC字符串歌词解析SRT对象 +func Lrc2Srt(src string) *SRT { + if src == "" { + return nil + } + //标准的LRC文件为一行一句歌词 + lyrics := strings.Split(src, "\n") + //标识标签的正则 [ar:A-SOUL]形式 + infoRegx := regexp.MustCompile(`^\[([a-z]+):([\s\S]*)]`) + + srt := &SRT{Content: make([]*SRTContent, 0, len(lyrics))} + //解析标识信息 + for { + if len(lyrics) == 0 { + break + } + l := lyrics[0] + //根据正则表达式进行匹配 + info := infoRegx.FindStringSubmatch(l) + //标识信息位于歌词信息前面,当出现未匹配成功时,即可退出循环 + if info != nil { + //info 中为匹配成功的字符串和 子组合(正则表达式中括号包裹的部分) + //例如,对于标识信息:[ar:A-SOUL],info中的数据为[[ar:A-SOUL] ar A-SOUL] + key := info[1] + switch key { + case "ar": + //歌手名 + if len(info) == 3 { + srt.Artist = info[2] + } + case "ti": + //歌曲名 + if len(info) == 3 { + srt.Title = info[2] + } + } + lyrics = lyrics[1:] + } else { + break + } + } + //歌词信息的正则,"[00:10.222]超级的敏感"或“[00:10:222]超级的敏感”或“[00:10]超级的敏感”或“[00:10.22]超级的敏感”或“[00:10:22]超级的敏感” + lyricRegx := regexp.MustCompile(`\[(\d\d):(\d\d)([.:]\d{2,3})?]([\s\S]+)`) + index := 0 + for _, l := range lyrics { + content := lyricRegx.FindStringSubmatch(l) + if content != nil { + c := SplitLyric(content[1:]) + if c != nil { + if index != 0 { + //前一条字幕的结束时间为当前字幕开始的时间 + srt.Content[index-1].End = c.Start + } + srt.Content = append(srt.Content, c) + index++ + } + } + } + //最后一条字幕 + last := srt.Content[index-1] + //最后一条字幕的结束时间为其开始时间 + 10秒 + last.End = last.Start + 10000 + return srt +} + +// SplitLyric 对分割出来的歌词信息进行解析 +func SplitLyric(src []string) *SRTContent { + minute, err := strconv.Atoi(src[0]) + second, err := strconv.Atoi(src[1]) + if err != nil { + fmt.Printf("错误的时间格式:%s\n", src) + return nil + } + millisecond, content := 0, "" + + _len := len(src) + if _len == 3 { + //歌词信息没有毫秒值 + content = src[2] + } else if _len == 4 { + content = src[3] + //字符串的第一个字符是 "." 或 ":" + ms := src[2][1:] + millisecond, err = strconv.Atoi(ms) + //QQ音乐歌词文件中,毫秒值只有两位,需要特殊处理一下 + if len(ms) == 2 { + millisecond *= 10 + } + if err != nil { + fmt.Printf("错误的时间格式:%s\n", src) + return nil + } + } + + srtContent := &SRTContent{} + srtContent.Start = Time2Millisecond(minute, second, millisecond) + srtContent.Text = content + return srtContent +} + +//返回SRT文件中,一句字幕的字符串表示形式 +/** +1 +00:00:01,111 --> 00:00:10,111 +字幕 + +*/ +func (s *SRTContent) String() string { + builder := strings.Builder{} + builder.WriteString(strconv.Itoa(s.Index)) + builder.WriteByte('\n') + sh, sm, ss, sms := Millisecond2Time(s.Start) + eh, em, es, ems := Millisecond2Time(s.End) + builder.WriteString(fmt.Sprintf("%02d:%02d:%02d,%03d --> %02d:%02d:%02d,%03d\n", + sh, sm, ss, sms, eh, em, es, ems)) + builder.WriteString(s.Text) + builder.WriteString("\n\n") + + return builder.String() +} diff --git a/qqlyric.go b/qqlyric.go new file mode 100644 index 0000000..89cef12 --- /dev/null +++ b/qqlyric.go @@ -0,0 +1,62 @@ +package main + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" +) + +type QQLyric struct { + RetCode int `json:"retcode"` + Code int `json:"code"` + SubCode int `json:"subcode"` + Lyric string `json:"lyric"` + Trans string `json:"trans"` +} + +func GetQQLyric(id string) (lyric, tLyric string) { + api := "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" + params := url.Values{} + //返回格式 + params.Add("format", "json") + params.Add("inCharset", "utf-8") + params.Add("outCharset", "utf-8") + params.Add("platform", "yqq.json") + params.Add("g_tk", "5381") + //歌曲的id号 + params.Add("songmid", id) + //返回结果为原始结果,而不是base64编码的结果(base64编码后数据量会增大) + params.Add("nobase64", "1") + + api = fmt.Sprintf("%s?%s", api, params.Encode()) + + req, _ := http.NewRequest("GET", api, nil) + //必须设置Referer,否则会请求失败 + req.Header.Add("Referer", "https://y.qq.com") + req.Header.Add("User-Agent", ChromeUA) + req.Header.Add("accept-encoding", "gzip") + resp, err := client.Do(req) + if err != nil { + fmt.Printf("网络错误:%v\n", err) + os.Exit(1) + } + + if resp == nil || resp.StatusCode != http.StatusOK { + fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode) + os.Exit(1) + } + defer resp.Body.Close() + + //返回的数据是gzip压缩,需要解压 + reader, _ := gzip.NewReader(resp.Body) + var qqLyric QQLyric + err = json.NewDecoder(reader).Decode(&qqLyric) + if qqLyric.RetCode != 0 { + fmt.Printf("获取歌词失败,返回的结果为:%+v,请检查id是否正确\n", qqLyric) + os.Exit(1) + } + return qqLyric.Lyric, qqLyric.Trans +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..89a67da --- /dev/null +++ b/util.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "io" + "os" +) + +// Time2Millisecond 根据分,秒,毫秒 计算出对应的毫秒值 +func Time2Millisecond(m, s, ms int) int { + t := m*60 + s + t *= 1000 + t += ms + return t +} + +// Millisecond2Time 根据毫秒值计算出对应的 时,分,秒,毫秒形式的时间值 +func Millisecond2Time(millisecond int) (h, m, s, ms int) { + ms = millisecond % 1000 + + s = millisecond / 1000 + m = s / 60 + h = m / 60 + + s %= 60 + m %= 60 + return +} + +func ReadFile(name string) string { + if name == "" { + return "" + } + file, err := os.Open(name) + if err != nil { + fmt.Printf("打开文件失败:%v\n", err) + os.Exit(1) + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + fmt.Printf("读取文件失败:%v\n", err) + os.Exit(1) + } + return string(data) +} + +func WriteFile(name string, data string) { + file, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE, os.ModePerm) + if err != nil { + fmt.Printf("保存文件失败:%v\n", err) + os.Exit(1) + } + defer file.Close() + nw, err := file.WriteString(data) + if err != nil || nw < len(data) { + fmt.Printf("保存文件失败:%v\n", err) + os.Exit(1) + } +}