diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 863c15a..aef47b9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,25 +13,14 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Go - uses: actions/setup-go@v2 + - name: Setup Go environment + uses: actions/setup-go@v3.0.0 with: - go-version: 1.17 + go-version: '>=1.18' check-latest: true - name: Build run: go build -v ./... - #执行测试 - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Set up GO - uses: actions/setup-go@v2 - with: - go-version: 1.17 - check-latest: true - name: Test run: go test -run=Test diff --git a/.gitignore b/.gitignore index 5bb3f1e..53ceaab 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ fabric.properties # Dependency directories (remove the comment below to include it) # vendor/ -/example/*.srt -/example/*.lrc +*.srt +*.lrc +*.ass /temp diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..4fcc028 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +ltc \ No newline at end of file diff --git a/.idea/lrc2srt.iml b/.idea/ltc.iml similarity index 71% rename from .idea/lrc2srt.iml rename to .idea/ltc.iml index a41de17..5e764c4 100644 --- a/.idea/lrc2srt.iml +++ b/.idea/ltc.iml @@ -2,9 +2,7 @@ - - - + diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..283b9b4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 19b0b7d..d70defd 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index a59a074..26733ff 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ -# LrcToSrt +# LrcToCaptons [![Build](https://github.com/Hami-Lemon/LrcToSrt/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/Hami-Lemon/LrcToSrt/actions/workflows/go.yml) -用于将LRC歌词文件转换成SRT字幕文件 +用于将LRC歌词文件转换成ASS、SRT字幕文件 ## 功能 - [x] lrc文件转换成srt文件 -- [x] 从网易云音乐或QQ音乐上获取歌词,并转换成srt文件 -- [x] 从网易云音乐或QQ音乐上下载歌词 +- [x] lrc文件转换成ass文件 +- [x] 从网易云音乐或QQ音乐上获取歌词,并转换。 +- [x] 从网易云音乐或QQ音乐上下载歌词。 ## 下载 @@ -16,21 +17,25 @@ ## 开始使用 -``` -Usage: - D:\ProgrameStudy\lrc2srt\lts.exe [OPTIONS] +```text +LrcToCaptions(ltc) 将LRC歌词文件转换成字幕文件。 +ltc version: "0.3.4" (build 2022.03.30) -Application Options: - -i, --id= 歌曲的id,网易云和QQ音乐均可。 - -I, --input= 需要转换的LRC文件路径。 - -s, --source=[163|qq|QQ] 当设置id时有效,指定从网易云(163)还是QQ音乐(qq)上获取歌词。 - (default: 163) - -d, --download 只下载歌词,而不进行解析。 - -m, --mode=[1|2|3] 原文和译文的排列模式,可选值有:[1] [2] [3] (default: 1) - -v, --version 获取版本信息 +用法:ltc [options] OutputFile -Help Options: - -h, --help Show this help message +options: + + -d 设置该选项时,只下载歌词,而无需转换。 + -f value + 转换成的字幕文件格式,可选值:ass(默认),srt + -i string + 歌词来源,可以是歌词对应的歌曲id,也可以是歌词文件 + -m value + 设置歌词原文和译文的合并模式,可选值:1(默认),2,3。 + -s string + 选择从网易云还是QQ音乐上获取歌词,可选值:163(默认),qq。 (default "163") + -v 获取当前程序版本信息。 + -h 显示帮助信息。 ``` ### 获取歌曲id @@ -145,3 +150,18 @@ lts -i 003FJlVU1rxjv8 -m 2 -s qq "ふわふわ时间.srt" ## 结束时间处理策略 因为在LRC文件中,并不包含一句歌词的结束时间,所以在转换成SRT文件时,处理策略为,**一句歌词的结束时间为下一句歌词的开始时间,最后一句歌词的结束时间为其`开始时间+10秒`**,所以在打轴时,对进入间奏的地方应该手动调整歌词的结束时间。 + +## 源码编译 + +### 环境需求 + +- [Go 1.18+](https://golang.google.cn/dl/) + +### 编译 + +```bash +git clone https://github.com/Hami-Lemon/ltc.git +cd ./ltc/lrctocaptions +go build -o ltc.exe . +``` + diff --git a/ass.go b/ass.go new file mode 100644 index 0000000..c130679 --- /dev/null +++ b/ass.go @@ -0,0 +1,125 @@ +package ltc + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Hami-Lemon/ltc/glist" +) + +type ASSNode struct { + Start int //开始时间 + End int //结束时间 + Dialogue string //内容 +} + +func (a *ASSNode) String() string { + builder := strings.Builder{} + sh, sm, ss, sms := millisecond2Time(a.Start) + eh, em, es, ems := millisecond2Time(a.End) + builder.WriteString("Dialogue: 0,") + sms /= 10 + ems /= 10 + builder.WriteString(fmt.Sprintf("%d:%02d:%02d.%02d,%d:%02d:%02d.%02d,Default,,", + sh, sm, ss, sms, eh, em, es, ems)) + builder.WriteString("0000,0000,0000,,") + builder.WriteString(a.Dialogue) + return builder.String() +} + +type ASS struct { + Content glist.Queue[*ASSNode] +} + +func LrcToAss(lrc *LRC) *ASS { + return SrtToAss(LrcToSrt(lrc)) +} + +func SrtToAss(srt *SRT) *ASS { + if srt == nil { + return nil + } + ass := &ASS{ + Content: glist.NewLinkedList[*ASSNode](), + } + for it := srt.Content.Iterator(); it.Has(); { + s := it.Next() + node := &ASSNode{ + Start: s.Start, + End: s.End, + Dialogue: s.Text, + } + ass.Content.PushBack(node) + } + return ass +} + +func (a *ASS) WriteFile(path string) error { + f, err := os.Create(path) + if err != nil { + //不存在对应文件夹 + if os.IsNotExist(err) { + panic("文件夹不存在:" + filepath.Dir(path)) + } + return err + } + err = a.Write(f) + err = f.Close() + return err +} + +func (a *ASS) Write(dst io.Writer) error { + if err := writeScriptInfo(dst); err != nil { + return err + } + if err := writeStyles(dst); err != nil { + return err + } + if err := writeEventHeader(dst); err != nil { + return err + } + for it := a.Content.Iterator(); it.Has(); { + temp := it.Next() + r := temp.String() + _, err := fmt.Fprintf(dst, "%s\n", r) + if err != nil { + return err + } + } + return nil +} + +func writeScriptInfo(dst io.Writer) error { + text := `[Script Info] +Title: LRC ASS file +ScriptType: v4.00+ +PlayResX: 1920 +PlayResY: 1080 +Collisions: Reverse +WrapStyle: 2 + +` + _, err := dst.Write([]byte(text)) + return err +} + +func writeStyles(dst io.Writer) error { + text := `[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,黑体,36,&H00FFFFFF,&H00FFFFFF,&H00000000,&00FFFFFF,-1,0,0,0,100,100,0,0,1,0,1,2,0,0,0,1 + +` + _, err := dst.Write([]byte(text)) + return err +} + +func writeEventHeader(dst io.Writer) error { + text := `[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +` + _, err := dst.Write([]byte(text)) + return err +} diff --git a/build.bat b/build.bat deleted file mode 100644 index f6cf966..0000000 --- a/build.bat +++ /dev/null @@ -1,3 +0,0 @@ -echo off -@REM forceposix 表示在windows上参数也为linux风格,即以“-”开头 -go build -tags="forceposix" -ldflags "-s -w" -o lts.exe . diff --git a/cloudlyric.go b/cloudlyric.go index baac27a..e46127c 100644 --- a/cloudlyric.go +++ b/cloudlyric.go @@ -1,11 +1,10 @@ -package main +package ltc import ( "encoding/json" "fmt" "net/http" "net/url" - "os" ) type CloudLyricBase struct { @@ -38,15 +37,15 @@ func Get163Lyric(id string) (lyric, tLyric string) { req, _ := http.NewRequest("GET", api, nil) //必须设置Referer,否则会请求失败 req.Header.Add("Referer", "https://music.163.com") - req.Header.Add("User-Agent", ChromeUA) + req.Header.Add("User-Agent", CHROME_UA) resp, err := client.Do(req) if err != nil { fmt.Printf("网络错误:%v\n", err) - os.Exit(1) + panic("网络异常,获取失败。") } if resp == nil || resp.StatusCode != http.StatusOK { fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode) - os.Exit(1) + panic("获取失败,未能正确获取到数据") } defer resp.Body.Close() @@ -54,7 +53,7 @@ func Get163Lyric(id string) (lyric, tLyric string) { err = json.NewDecoder(resp.Body).Decode(&cloudLyric) if cloudLyric.Sgc { fmt.Printf("获取歌词失败,返回的结果为:%+v,请检查id是否正确\n", cloudLyric) - os.Exit(1) + panic("id错误,获取歌词失败") } return cloudLyric.Lrc.Lyric, cloudLyric.TLyric.Lyric } diff --git a/cloudlyric_test.go b/cloudlyric_test.go new file mode 100644 index 0000000..0d82d25 --- /dev/null +++ b/cloudlyric_test.go @@ -0,0 +1,22 @@ +package ltc + +import ( + "fmt" + "testing" +) + +func TestGet163Lyric(t *testing.T) { + tests := []struct { + input string + }{ + {"1423123512"}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("id=%s", tt.input), func(t *testing.T) { + l, lt := Get163Lyric(tt.input) + if l == "" || lt == "" { + t.Errorf("get cloud lyric faild, id = %s", tt.input) + } + }) + } +} diff --git a/glist/linkedlist.go b/glist/linkedlist.go new file mode 100644 index 0000000..b4c121a --- /dev/null +++ b/glist/linkedlist.go @@ -0,0 +1,268 @@ +package glist + +import ( + "math" +) + +const ( + // CAPACITY 链表的最大容量 + CAPACITY uint = math.MaxUint + ZERO = uint(0) //uint类型的0 +) + +// Node 链表中的一个结点 +type Node[E any] struct { + element E //保存的内容 + prev *Node[E] //前一个结点 + next *Node[E] //后一个结点 +} + +// Clone 克隆Node,返回的Node的Prev和Next均为nil,Element保持不变 +func (n *Node[E]) Clone() *Node[E] { + node := &Node[E]{ + element: n.element, + prev: nil, + next: nil, + } + return node +} + +// LinkedList 链表,实现了List +type LinkedList[E any] struct { + len uint //链表中元素个数 + first *Node[E] //头指针 + last *Node[E] //尾指针 +} + +// NewLinkedList 创建一个链表, +//列表的最大容量为uint类型的最大值 +func NewLinkedList[E any]() *LinkedList[E] { + return &LinkedList[E]{ + len: 0, + first: nil, + last: nil, + } +} + +func (l *LinkedList[E]) Size() uint { + return l.len +} + +func (l *LinkedList[E]) IsEmpty() bool { + return l.len == 0 +} + +func (l *LinkedList[E]) IsNotEmpty() bool { + return l.len != 0 +} + +func (l *LinkedList[E]) Append(element E) bool { + //超出最大值无法添加 + if l.len == CAPACITY { + return false + } + node := &Node[E]{ + element: element, + prev: nil, + next: nil, + } + //链表为空,头指针指向该结点 + if l.first == nil { + l.first = node + l.last = node + } else { + //链表不为空,添加到尾部 + node.prev = l.last + l.last.next = node + l.last = node + } + l.len++ + return true +} + +func (l *LinkedList[E]) Insert(index uint, element E) bool { + //当前size已经达到最大值或者索引越界 + if l.len == CAPACITY || index > l.len { + return false + } + node := &Node[E]{ + element: element, + prev: nil, + next: nil, + } + //插入头部 + if index == 0 { + if l.first == nil { + //链表为空 + l.first = node + l.last = node + } else { + //链表不为空 + node.next = l.first + l.first.prev = node + l.first = node + } + } else if index == l.len { + //插入尾部 + l.last.next = node + node.prev = l.last + l.last = node + } else { + var prev *Node[E] + head := l.first + for i := ZERO; i < index; i++ { + prev = head + head = head.next + } + node.next = head + node.prev = prev + prev.next = node + head.prev = node + } + l.len++ + return true +} + +func (l *LinkedList[E]) Remove(index uint) bool { + if index >= l.len { + return false + } + head := l.first + var prev *Node[E] + for i := ZERO; i < index; i++ { + prev = head + head = head.next + } + //删除第一个结点 + if head == l.first { + l.first.next = nil + l.first = head.next + } else if head == l.last { + //删除最后一个结点 + l.last = prev + l.last.next = nil + } else { + prev.next = head.next + head.next.prev = prev + } + l.len-- + return true +} + +func (l *LinkedList[E]) Get(index uint) *E { + if index >= l.len { + return nil + } + node := l.first + for i := ZERO; i < index; i++ { + node = node.next + } + return &(node.element) +} + +func (l *LinkedList[E]) Set(index uint, element E) bool { + if index >= l.len { + return false + } + node := l.first + for i := ZERO; i < index; i++ { + node = node.next + } + node.element = element + return true +} + +func (l *LinkedList[E]) PushBack(element E) bool { + return l.Append(element) +} + +func (l *LinkedList[E]) PushFront(element E) bool { + return l.Insert(0, element) +} + +func (l *LinkedList[E]) PopBack() *E { + //链表为空 + if l.len == 0 { + return nil + } + node := l.last + //只有一个元素 + if l.len == 1 { + l.last = nil + l.first = nil + } else { + l.last = node.prev + l.last.next = nil + } + l.len-- + return &(node.element) +} + +func (l *LinkedList[E]) PopFront() *E { + if l.len == 0 { + return nil + } + node := l.first + if l.len == 1 { + l.first = nil + l.last = nil + } else { + l.first = node.next + l.first.prev = nil + } + l.len-- + return &(node.element) +} + +func (l *LinkedList[E]) PullBack() *E { + if l.len == 0 { + return nil + } + return &(l.last.element) +} + +func (l *LinkedList[E]) PullFront() *E { + if l.len == 0 { + return nil + } + return &(l.first.element) +} + +// Iterator 获取该链表的迭代器 +func (l *LinkedList[E]) Iterator() Iterator[E] { + return &LinkedListIterator[E]{ + reverse: false, + next: l.first, + } +} + +// ReverseIterator 获取反向迭代器 +func (l *LinkedList[E]) ReverseIterator() Iterator[E] { + return &LinkedListIterator[E]{ + reverse: true, + next: l.last, + } +} + +type LinkedListIterator[E any] struct { + //是否反向,如果为true,则是从尾部向头部迭代 + reverse bool + next *Node[E] +} + +func (l *LinkedListIterator[E]) Has() bool { + return l.next != nil +} + +func (l *LinkedListIterator[E]) Next() E { + e := l.next + if e == nil { + panic("iterator is empty.") + } + if l.reverse { + l.next = e.prev + } else { + l.next = e.next + } + return e.element +} diff --git a/glist/linkedlist_test.go b/glist/linkedlist_test.go new file mode 100644 index 0000000..f9558c5 --- /dev/null +++ b/glist/linkedlist_test.go @@ -0,0 +1,209 @@ +package glist + +import ( + "math/rand" + "reflect" + "testing" +) + +func checkListElement[T comparable](list *LinkedList[T], items []T, t *testing.T) { + if list.len != uint(len(items)) { + t.Errorf("list len= %d, items len = %d.", list.len, len(items)) + } + i := 0 + for it := list.Iterator(); it.Has(); { + e := it.Next() + if e != items[i] { + t.Errorf("index=%d,except:%v, but got:%v", i, items[i], e) + } + i++ + } +} + +func TestLinkedListIterator_Has(t *testing.T) { + //空列表 + t.Run("empty list", func(t *testing.T) { + emptyList := NewLinkedList[int]() + it := emptyList.Iterator() + if it.Has() { + t.Errorf("iterator should empty, but not") + } + }) + //非空列表 + t.Run("non-empty list", func(t *testing.T) { + list := NewLinkedList[int]() + list.Append(1) + list.Append(2) + it := list.Iterator() + if !(it.Has()) { + t.Errorf("iterator should not empty, but not") + } + it.Next() + it.Next() + if it.Has() { + t.Errorf("iterator should empty, but not") + } + }) +} + +func TestLinkedListIterator_Next(t *testing.T) { + t.Run("empty list", func(t *testing.T) { + defer func() { + if p := recover(); p == nil { + t.Errorf("iterator is empty, should panic.") + } + }() + emptyList := NewLinkedList[int]() + it := emptyList.Iterator() + it.Next() + }) + + t.Run("non-empty list", func(t *testing.T) { + defer func() { + if p := recover(); p != nil { + t.Errorf("iterator is non-empty, should no panic.") + } + }() + list := NewLinkedList[int]() + times := rand.Int() % 20 //20次以内 + for i := times; i >= 0; i-- { + list.Append(i) + } + it := list.Iterator() + for i := times; i >= 0; i-- { + it.Next() + } + }) +} + +func TestLinkedList_Append(t *testing.T) { + var sliceWithTenNum [10]int + for i := 0; i < 10; i++ { + sliceWithTenNum[i] = i + } + tests := []struct { + name string + input []int + }{ + {"Append 0 value", []int{}}, + {"Append 10 value", sliceWithTenNum[:]}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + list := NewLinkedList[int]() + for _, v := range tt.input { + list.Append(v) + } + checkListElement(list, tt.input, t) + }) + } +} + +func TestLinkedList_Size(t *testing.T) { + tests := []struct { + name string + want uint + }{ + {"empty list", 0}, + {"10 values list", 10}, + {"20 values list", 20}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num := tt.want + list := NewLinkedList[int]() + for i := uint(0); i < num; i++ { + list.Append(int(i)) + } + if got := list.Size(); got != tt.want { + t.Errorf("Size() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLinkedList_IsEmpty(t *testing.T) { + nonEmpty := NewLinkedList[int]() + nonEmpty.Append(1) + + tests := []struct { + name string + list *LinkedList[int] + want bool + }{ + {"empty list", NewLinkedList[int](), true}, + {"non-empty list", nonEmpty, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := tt.list + if got := l.IsEmpty(); got != tt.want { + t.Errorf("IsEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLinkedList_Remove(t *testing.T) { + tests := []struct { + name string + listNum int + args uint + want bool + values []int + }{ + {"empty list", 0, 0, false, nil}, + {"index gather than len", 0, 1, false, nil}, + {"1 value list:delete index 0", 1, 0, true, []int{}}, + {"10 value list:delete index 0", 10, 0, true, []int{1, 2, 3, 4, 5, 6, 7, 8, 9}}, + {"10 value list:delete index 5", 10, 5, true, []int{0, 1, 2, 3, 4, 6, 7, 8, 9}}, + {"10 value list:delete index 9", 10, 9, true, []int{0, 1, 2, 3, 4, 5, 6, 7, 8}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := NewLinkedList[int]() + for i := 0; i < tt.listNum; i++ { + l.Append(i) + } + if got := l.Remove(tt.args); got != tt.want { + t.Errorf("Remove() = %v, want %v", got, tt.want) + } + if tt.want { + checkListElement(l, tt.values, t) + } + }) + } +} + +func TestLinkedList_Get(t *testing.T) { + tests := []struct { + name string + listNum int + args uint + want int + }{ + {"empty list,index 0", 0, 0, -1}, + {"empty list,index 1", 0, 1, -1}, + {"1 value list, index 0", 1, 0, 0}, + {"10 value list, index 0", 10, 0, 0}, + {"10 value list, index 5", 10, 5, 5}, + {"10 value list, index 9", 10, 9, 9}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := NewLinkedList[int]() + for i := 0; i < tt.listNum; i++ { + l.Append(i) + } + want := new(int) + if tt.want == -1 { + want = nil + } else { + *want = tt.want + } + if got := l.Get(tt.args); !reflect.DeepEqual(got, want) { + t.Errorf("Get() = %v, want %v", got, want) + } + }) + } +} diff --git a/glist/list.go b/glist/list.go new file mode 100644 index 0000000..ca26569 --- /dev/null +++ b/glist/list.go @@ -0,0 +1,50 @@ +package glist + +// List 一个基本的列表 +type List[E any] interface { + // Size 获取列表中数据个数 + Size() uint + //IsEmpty 判断列表是否为空,如果为空,返回true,否则返回false + IsEmpty() bool + // IsNotEmpty 判断列表是否非空,如果列表不为空,返回true,否则返回false + IsNotEmpty() bool + // Append 向列表尾部添加一个元素 + Append(element E) bool + // Insert 向列表指定索引处插入一个元素,如果插入成功返回true,否则返回false + Insert(index uint, element E) bool + // Remove 从列表中移除元素element,如果元素不存在,则返回false + Remove(index uint) bool + // Get 从列表中获取索引为index元素的指针,索引从0开始,如果索引超出范围则返回nil + Get(index uint) *E + // Set 改变列表中索引为index的元素的值,如果索引超出范围则返回false + Set(index uint, element E) bool + // Iterator 获取列表的迭代器 + Iterator() Iterator[E] + // ReverseIterator 反向迭代器,从尾部向前迭代 + ReverseIterator() Iterator[E] +} + +// Queue 队列 +type Queue[E any] interface { + List[E] + // PushBack 队列尾部添加元素,添加成功返回true + PushBack(element E) bool + // PushFront 队列头部添加元素,添加成功返回true + PushFront(element E) bool + // PopBack 删除队列尾部的元素,返回被删除的元素的指针,如果队列为空,则返回nil + PopBack() *E + // PopFront 删除队列头部的元素,返回被删除元素的指针,如果队列为空,返回nil + PopFront() *E + // PullBack 获取队列尾部的元素的指针,不会删除,如果队列为空,返回nil + PullBack() *E + // PullFront 获取队列头部的元素的指针,不会删除,如果队列为空,返回nil + PullFront() *E +} + +// Iterator 列表迭代器 +type Iterator[E any] interface { + // Has 是否还有元素 + Has() bool + // Next 获取元素 + Next() E +} diff --git a/go.mod b/go.mod index 77c5a2a..d2e7b41 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,3 @@ -module github.com/hami_lemon/lrc2srt +module github.com/Hami-Lemon/ltc -go 1.17 - -require github.com/jessevdk/go-flags v1.5.0 - -require golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 // indirect +go 1.18 diff --git a/go.sum b/go.sum index df31363..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +0,0 @@ -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/lrc.go b/lrc.go new file mode 100644 index 0000000..049d304 --- /dev/null +++ b/lrc.go @@ -0,0 +1,128 @@ +package ltc + +import ( + "regexp" + "strconv" + "strings" + + "github.com/Hami-Lemon/ltc/glist" +) + +type LRCNode struct { + //歌词出现的时间,单位毫秒 + time int + //歌词内容 + content string +} + +type LRC struct { + //歌曲名 + Title string + //歌手名 + Artist string + //专辑名 + Album string + //歌词作者 + Author string + //歌词列表 + LrcList glist.Queue[*LRCNode] +} + +func ParseLRC(src string) *LRC { + if src == "" { + return nil + } + //标准的LRC文件为一行一句歌词 + lyrics := strings.Split(src, "\n") + //标识标签的正则 [ar:A-SOUL]形式 + infoRegx := regexp.MustCompile(`^\[([a-z]+):([\s\S]*)]`) + + lrc := &LRC{LrcList: glist.NewLinkedList[*LRCNode]()} + //解析标识信息 + 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 { + lrc.Artist = info[2] + } + case "ti": + //歌曲名 + if len(info) == 3 { + lrc.Title = info[2] + } + case "al": + //专辑名 + if len(info) == 3 { + lrc.Album = info[2] + } + case "by": + //歌词作者 + if len(info) == 3 { + lrc.Author = 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]+)`) + for _, l := range lyrics { + content := lyricRegx.FindStringSubmatch(l) + if content != nil { + node := SplitLyric(content[1:]) + if node != nil { + lrc.LrcList.PushBack(node) + } + } + } + return lrc +} + +// SplitLyric 对分割出来的歌词信息进行解析 +func SplitLyric(src []string) *LRCNode { + minute, err := strconv.Atoi(src[0]) + second, err := strconv.Atoi(src[1]) + if err != nil { + panic("错误的时间格式:" + strings.Join(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 { + panic("错误的时间格式:" + strings.Join(src, " ")) + return nil + } + } + lrcNode := &LRCNode{ + time: time2Millisecond(minute, second, millisecond), + content: strings.TrimSpace(content), //去掉前后的空格 + } + return lrcNode +} diff --git a/lrc_test.go b/lrc_test.go new file mode 100644 index 0000000..2104117 --- /dev/null +++ b/lrc_test.go @@ -0,0 +1,68 @@ +package ltc + +import ( + "reflect" + "testing" +) + +func TestParseLRC(t *testing.T) { + lrc := `[ar:artist] +[al:album] +[ti:title] +[by:author] +[00:24.83] 天涯的尽头 有谁去过 +[00:28.53] 山水优雅着 保持沉默 +[00:32.20] 我们的青春却热闹很多 +[00:35.38] 而且是谁都 不准偷 +` + content := []string{ + "天涯的尽头 有谁去过", "山水优雅着 保持沉默", "我们的青春却热闹很多", "而且是谁都 不准偷", + } + l := ParseLRC(lrc) + if l.Artist != "artist" { + t.Errorf("LRC Artist=%s, want=%s", l.Artist, "artist") + } + if l.Album != "album" { + t.Errorf("LRC Album=%s, want=%s", l.Album, "album") + } + if l.Title != "title" { + t.Errorf("LRC Title=%s, want=%s", l.Title, "title") + } + if l.Author != "author" { + t.Errorf("LRC Author=%s, want=%s", l.Author, "author") + } + lrcList := l.LrcList + index := 0 + for it := lrcList.Iterator(); it.Has(); { + c := it.Next().content + if c != content[index] { + t.Errorf("LRCNode Content=%s, want=%s", c, content[index]) + } + index++ + + } +} + +func TestSplitLyric(t *testing.T) { + tests := []struct { + name string + args []string + want *LRCNode + }{ + {"lrc:[00:49.88] 有一些话想 对你说", []string{"00", "49", ".88", " 有一些话想 对你说"}, + &LRCNode{time: time2Millisecond(0, 49, 880), content: "有一些话想 对你说"}}, + {"lrc:[00:49:88] 有一些话想 对你说", []string{"00", "49", ":88", " 有一些话想 对你说"}, + &LRCNode{time: time2Millisecond(0, 49, 880), content: "有一些话想 对你说"}}, + {"lrc:[00:49.880] 有一些话想 对你说", []string{"00", "49", ".880", " 有一些话想 对你说"}, + &LRCNode{time: time2Millisecond(0, 49, 880), content: "有一些话想 对你说"}}, + {"lrc:[00:49] 有一些话想 对你说", []string{"00", "49", " 有一些话想 对你说"}, + &LRCNode{time: time2Millisecond(0, 49, 0), content: "有一些话想 对你说"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SplitLyric(tt.args); !reflect.DeepEqual(got, tt.want) { + t.Errorf("SplitLyric() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/lrctocaptions/flag.go b/lrctocaptions/flag.go new file mode 100644 index 0000000..7e93535 --- /dev/null +++ b/lrctocaptions/flag.go @@ -0,0 +1,196 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "github.com/Hami-Lemon/ltc" + "os" + "strconv" + "strings" +) + +var ( + input string //输入,可以是歌词对应的歌曲id,也可以是文件名 + source string //歌词来源,默认163,可选163(网易云音乐),QQ或qq(QQ音乐),后续支持:kg(酷狗音乐) + download boolFlag //是否只下载歌词,当输入是歌曲id且设置该选项时,只下载歌词而不进行处理 + mode modeFlag //如果存在译文时的合并模式 + version boolFlag //当前程序版本信息,设置该选项时只输出版本信息 + format formatFlag //字幕格式,可选: ass,srt,默认为ass + output string //保存的文件名 +) + +//检查路径path是否有效 +func checkPath(path string) bool { + if _, err := os.Stat(path); err == nil { + return true + } else { + return false + } +} + +func parseFlag() { + flag.StringVar(&input, "i", "", "歌词来源,可以是歌词对应的歌曲id,也可以是歌词文件") + flag.StringVar(&source, "s", "163", "选择从网易云还是QQ音乐上获取歌词,可选值:163(默认),qq。") + flag.Var(&download, "d", "设置该选项时,只下载歌词,而无需转换。") + flag.Var(&mode, "m", "设置歌词原文和译文的合并模式,可选值:1(默认),2,3。") + flag.Var(&version, "v", "获取当前程序版本信息。") + flag.Var(&format, "f", "转换成的字幕文件格式,可选值:ass(默认),srt") + flag.Usage = func() { + fmt.Printf("LrcToCaptions(ltc) 将LRC歌词文件转换成字幕文件。\n") + fmt.Printf("ltc version: %s\n\n", VERSION) + fmt.Printf("用法:ltc [options] OutputFile\n\n") + fmt.Printf("options:\n\n") + flag.PrintDefaults() + fmt.Println("") + } + flag.Parse() + if other := flag.Args(); len(other) != 0 { + output = other[0] + } + outputProcess() +} + +func outputProcess() { + //处理结果文件名 + if output == "" { + //和输入源同名 + dot := strings.LastIndex(input, ".") + if dot == -1 { + output = input + } else { + output = input[:dot] + } + } + //后缀名处理 + suffix := func(o, s string) string { + if !strings.HasSuffix(o, s) { + return o + s + } + return o + } + if download.IsSet() { + output = suffix(output, ".lrc") + } else { + switch format.Value() { + case FORMAT_SRT: + output = suffix(output, ".srt") + case FORMAT_ASS: + output = suffix(output, ".ass") + } + } +} + +// boolFlag bool值类型的参数 +//实现flags包中的boolFlag接口,设置bool值时不要传具体的值 +//即: -flag 等价与 -flag=true +type boolFlag bool + +func (b *boolFlag) String() string { + if b == nil { + return "false" + } + return strconv.FormatBool(bool(*b)) +} + +func (b *boolFlag) Set(value string) error { + if f, err := strconv.ParseBool(value); err != nil { + return err + } else { + *b = boolFlag(f) + return nil + } +} + +func (b *boolFlag) IsBoolFlag() bool { + return true +} + +func (b *boolFlag) IsSet() bool { + return bool(*b) +} + +//歌词合并模式的选项 +type modeFlag ltc.SRTMergeMode + +func (m *modeFlag) String() string { + if m == nil { + return "STACK_MODE" + } + switch ltc.SRTMergeMode(*m) { + case ltc.SRT_MERGE_MODE_STACK: + return "STACK_MODE" + case ltc.SRT_MERGE_MODE_UP: + return "UP_MODE" + case ltc.SRT_MERGE_MODE_BOTTOM: + return "BOTTOM_MODE" + default: + return "STACK_MODE" + } +} + +func (m *modeFlag) Set(value string) error { + if value == "" { + *m = modeFlag(ltc.SRT_MERGE_MODE_STACK) + } + v := strings.ToLower(value) + switch v { + case "1", "stack": + *m = modeFlag(ltc.SRT_MERGE_MODE_STACK) + case "2", "up": + *m = modeFlag(ltc.SRT_MERGE_MODE_UP) + case "3", "bottom": + *m = modeFlag(ltc.SRT_MERGE_MODE_BOTTOM) + default: + return errors.New("invalid mode value:" + v + " only support 1, 2, 3") + } + return nil +} + +func (m *modeFlag) Mode() ltc.SRTMergeMode { + return ltc.SRTMergeMode(*m) +} + +// Format 字幕文件的格式 +type Format int + +const ( + FORMAT_ASS Format = iota + FORMAT_SRT +) + +type formatFlag Format + +func (f *formatFlag) String() string { + if f == nil { + return "" + } + ft := Format(*f) + switch ft { + case FORMAT_SRT: + return "srt" + case FORMAT_ASS: + return "ass" + } + return "" +} + +func (f *formatFlag) Set(value string) error { + if value == "" { + *f = formatFlag(FORMAT_ASS) + } + v := strings.ToLower(value) + switch v { + case "srt": + *f = formatFlag(FORMAT_SRT) + case "ass": + *f = formatFlag(FORMAT_ASS) + default: + return errors.New("invalid format value:" + value) + } + return nil +} + +func (f *formatFlag) Value() Format { + return Format(*f) +} diff --git a/lrctocaptions/ltc.go b/lrctocaptions/ltc.go new file mode 100644 index 0000000..ee61fff --- /dev/null +++ b/lrctocaptions/ltc.go @@ -0,0 +1,122 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "github.com/Hami-Lemon/ltc" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +const ( + // VERSION 当前版本 + VERSION = `"0.3.4" (build 2022.03.30)` + VERSION_INFO = "LrcToCaptions(ltc) version: %s\n" +) + +func main() { + parseFlag() + //TODO 酷狗的krc精准到字,更利于打轴 https://shansing.com/read/392/ + //显示版本信息 + if version.IsSet() { + fmt.Printf(VERSION_INFO, VERSION) + return + } + //未指定来源 + if input == "" { + fmt.Printf("未指定歌词来源\n") + flag.Usage() + os.Exit(0) + } + //获取歌词,lyric为原文歌词,tranLyric为译文歌词 + var lyric, tranLyric string + //从文件中获取 + if checkPath(input) { + if !strings.HasSuffix(input, ".lrc") { + fmt.Println("Error: 不支持的格式,目前只支持lrc歌词文件。") + panic("") + } + if data, err := ioutil.ReadFile(input); err == nil { + if len(data) == 0 { + fmt.Println("获取歌词失败,文件内容为空。") + panic("") + } + lyric = string(data) + } else { + panic("读取文件失败:" + input + err.Error()) + } + } else { + //从网络上获取 + if source != "163" { + lyric, tranLyric = ltc.GetQQLyric(input) + } else { + lyric, tranLyric = ltc.Get163Lyric(input) + } + } + + //下载歌词 + if download.IsSet() { + //对文件名进行处理 + o := output + if o == "" { + o = input + ".lrc" + } else if !strings.HasSuffix(o, ".lrc") { + o += ".lrc" + } + writeFile(o, lyric) + if tranLyric != "" { + writeFile("tran_"+o, tranLyric) + } + fmt.Println("下载歌词完成!") + return + } + + lrc, lrcT := ltc.ParseLRC(lyric), ltc.ParseLRC(tranLyric) + //先转换成srt + srt, srtT := ltc.LrcToSrt(lrc), ltc.LrcToSrt(lrcT) + if srtT != nil { + //原文和译文合并 + srt.Merge(srtT, mode.Mode()) + } + switch format.Value() { + case FORMAT_SRT: + if err := srt.WriteFile(output); err != nil { + fmt.Println("出现错误,保存失败") + panic(err.Error()) + } + case FORMAT_ASS: + ass := ltc.SrtToAss(srt) + if err := ass.WriteFile(output); err != nil { + fmt.Println("出现错误,保存失败") + panic(err.Error()) + } + } + //如果是相对路径,则获取其对应的绝对路径 + if !filepath.IsAbs(output) { + //如果是相对路径,父目录即是当前运行路径 + dir, er := os.Getwd() + if er == nil { + output = dir + string(os.PathSeparator) + output + } + } + fmt.Printf("保存结果为:%s\n", output) +} + +func writeFile(file string, content string) { + f, err := os.Create(file) + if err != nil { + fmt.Printf("创建结果文件失败:%v\n", err) + panic("") + } + defer f.Close() + writer := bufio.NewWriter(f) + _, err = writer.WriteString(content) + err = writer.Flush() + if err != nil { + fmt.Printf("保存文件失败:%v\n", err) + panic("") + } +} diff --git a/lts.go b/lts.go deleted file mode 100644 index 0dcb32e..0000000 --- a/lts.go +++ /dev/null @@ -1,337 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "net/http" - "os" - "regexp" - "sort" - "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.1" (build 2022.03.05)` -) - -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] - } - - //获取歌词,lyric为原文歌词,tranLyric为译文歌词 - 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) - } - //原文和译文作为两条歌词流信息分开保存,但最终生成的srt文件会同时包含两个信息 - 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)} - //因为没有译文,所以mode选项无效,设为2之后,后面不用做多余判断 - opt.Mode = 2 - } - //处理结果文件的文件名 - 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.Create(name) - if err != nil { - fmt.Printf("创建结果文件失败:%v\n", err) - os.Exit(1) - } - defer func(file *os.File) { - _ = file.Close() - }(file) - //6KB的缓存区,大部分歌词生成的SRT文件均在4-6kb左右 - writer := bufio.NewWriterSize(file, 1024*6) - - /* - 原文和译文歌词的排列方式,因为原文歌词中可能包含一些非歌词信息, - 例如作词者,作曲者等,而在译文歌词中却可能不包含这些 - */ - //srt的序号 - index := 1 - switch opt.Mode { - case 1: - //将两个歌词合并成一个新的数组 - size := len(srt.Content) + len(tranSrt.Content) - temp := make([]*SRTContent, size, size) - i := 0 - for _, v := range srt.Content { - temp[i] = v - i++ - } - for _, v := range tranSrt.Content { - temp[i] = v - i++ - } - //按开始时间进行排序,使用SliceStable确保一句歌词的原文在译文之前 - sort.SliceStable(temp, func(i, j int) bool { - return temp[i].Start < temp[j].Start - }) - //写入文件 - for i, v := range temp { - v.Index = i + 1 - _, _ = writer.WriteString(v.String()) - } - 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) - } else { - 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/lts_test.go b/lts_test.go deleted file mode 100644 index c1762f9..0000000 --- a/lts_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import "testing" - -func BenchmarkSRTContent_String(b *testing.B) { - srt := SRTContent{ - Index: 10, - Start: 100, - End: 200, - Text: "言语 不起作用,想看到 具体行动", - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = srt.String() - } -} - -func BenchmarkLrc2Srt(b *testing.B) { - id := "28891491" - lyric, lyricT := Get163Lyric(id) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = Lrc2Srt(lyric), Lrc2Srt(lyricT) - } -} diff --git a/qqlyric.go b/qqlyric.go index a764c74..4fe3ad6 100644 --- a/qqlyric.go +++ b/qqlyric.go @@ -1,4 +1,4 @@ -package main +package ltc import ( "compress/gzip" @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/url" - "os" ) /** @@ -43,17 +42,17 @@ func GetQQLyric(id string) (lyric, tLyric string) { req, _ := http.NewRequest("GET", api, nil) //必须设置Referer,否则会请求失败 req.Header.Add("Referer", "https://y.qq.com") - req.Header.Add("User-Agent", ChromeUA) + req.Header.Add("User-Agent", CHROME_UA) req.Header.Add("accept-encoding", "gzip") resp, err := client.Do(req) if err != nil { fmt.Printf("网络错误:%v\n", err) - os.Exit(1) + panic("网络异常,请求失败。") } if resp == nil || resp.StatusCode != http.StatusOK { fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode) - os.Exit(1) + panic("获取失败,未能正确获取到数据") } defer resp.Body.Close() @@ -63,7 +62,7 @@ func GetQQLyric(id string) (lyric, tLyric string) { err = json.NewDecoder(reader).Decode(&qqLyric) if qqLyric.RetCode != 0 { fmt.Printf("获取歌词失败,返回的结果为:%+v,请检查id是否正确\n", qqLyric) - os.Exit(1) + panic("id错误,获取歌词失败。") } return qqLyric.Lyric, qqLyric.Trans } diff --git a/qqlyric_test.go b/qqlyric_test.go new file mode 100644 index 0000000..d339d6e --- /dev/null +++ b/qqlyric_test.go @@ -0,0 +1,22 @@ +package ltc + +import ( + "fmt" + "testing" +) + +func TestGetQQLyric(t *testing.T) { + tests := []struct { + input string + }{ + {"0002Jztl3eJKu0"}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("id=%s", tt.input), func(t *testing.T) { + l, lt := GetQQLyric(tt.input) + if l == "" || lt == "" { + t.Errorf("get cloud lyric faild, id = %s", tt.input) + } + }) + } +} diff --git a/srt.go b/srt.go new file mode 100644 index 0000000..e7e361b --- /dev/null +++ b/srt.go @@ -0,0 +1,202 @@ +package ltc + +import ( + "bufio" + "fmt" + "github.com/Hami-Lemon/ltc/glist" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +type SRTMergeMode int + +const ( + SRT_MERGE_MODE_STACK SRTMergeMode = iota + SRT_MERGE_MODE_UP + SRT_MERGE_MODE_BOTTOM +) + +type SRTContent struct { + //序号,从1开始,只在写入文件的时候设置这个属性 + Index int + //开始时间,单位毫秒 + Start int + //结束时间,单位毫秒 + End int + //歌词内容 + Text string +} + +/** +1 +00:00:01,111 --> 00:00:10,111 +字幕 + +*/ +//返回SRT文件中,一句字幕的字符串表示形式 +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() +} + +type SRT struct { + //歌曲名 + Title string + //歌手名 未指定文件名是,文件名格式为:歌曲名-歌手名.srt + Artist string + Content glist.Queue[*SRTContent] +} + +// LrcToSrt LRC对象转换成SRT对象 +func LrcToSrt(lrc *LRC) *SRT { + if lrc == nil { + return nil + } + srt := &SRT{ + Title: lrc.Title, + Artist: lrc.Artist, + Content: glist.NewLinkedList[*SRTContent](), + } + index := 1 + //上一条srt信息 + var prevSRT *SRTContent + for it := lrc.LrcList.Iterator(); it.Has(); { + lrcNode := it.Next() + srtContent := &SRTContent{ + Index: 0, + Start: lrcNode.time, + Text: lrcNode.content, + } + if index != 1 { + //上一条歌词的结束时间设置为当前歌词的开始时间 + prevSRT.End = srtContent.Start + } + srt.Content.PushBack(srtContent) + index++ + prevSRT = srtContent + } + //最后一条歌词 + if prevSRT != nil { + //结束时间是为其 开始时间+10 秒 + prevSRT.End = prevSRT.Start + 1000 + } + return srt +} + +// Merge 将另一个srt信息合并到当前srt中,有三种合并模式 +//1. SRT_MERGE_MODE_STACK: 按照开始时间对两个srt信息进行排序,交错合并 +//2. SRT_MERGE_MODE_UP: 当前srt信息排列在上,另一个排列在下,即 other 追加到后面 +//3. SRT_MERGE_MODE_BOTTOM: 当前srt信息排列在下,另一个排列在上,即 other 添加到前面 +func (s *SRT) Merge(other *SRT, mode SRTMergeMode) { + switch mode { + case SRT_MERGE_MODE_STACK: + s.mergeStack(other) + case SRT_MERGE_MODE_UP: + s.mergeUp(other) + case SRT_MERGE_MODE_BOTTOM: + s.mergeBottom(other) + } +} + +//可以类比为合并两个有序链表, +//算法参考:https://leetcode-cn.com/problems/merge-two-sorted-lists/solution/he-bing-liang-ge-you-xu-lian-biao-by-leetcode-solu/ +func (s *SRT) mergeStack(other *SRT) { + sIt, oIt := s.Content.Iterator(), other.Content.Iterator() + //不对原来的链表做修改,合并的信息保存在一个新的链表中 + merge := glist.NewLinkedList[*SRTContent]() + var sNode, oNode *SRTContent + //分别获取两个链表的第一个元素 + if sIt.Has() && oIt.Has() { + sNode, oNode = sIt.Next(), oIt.Next() + } + //开始迭代 + for sIt.Has() && oIt.Has() { + //小于等于,当相等时,s中的元素添加进去 + if sNode.Start <= oNode.Start { + merge.Append(sNode) + sNode = sIt.Next() + } else { + merge.Append(oNode) + oNode = oIt.Next() + } + } + if sNode != nil && oNode != nil { + //循环退出时,sNode和oNode指向的元素还没有进行比较,会导致缺少两条数据 + if sNode.Start <= oNode.Start { + merge.Append(sNode) + merge.Append(oNode) + } else { + merge.Append(oNode) + merge.Append(sNode) + } + } + //剩下的元素添加到链表中,最多只有一个链表有剩余元素 + for sIt.Has() { + merge.Append(sIt.Next()) + } + for oIt.Has() { + merge.Append(oIt.Next()) + } + s.Content = merge +} + +func (s *SRT) mergeUp(other *SRT) { + if other.Content.IsNotEmpty() { + for it := other.Content.Iterator(); it.Has(); { + s.Content.Append(it.Next()) + } + } +} + +func (s *SRT) mergeBottom(other *SRT) { + oq := other.Content + if oq.IsNotEmpty() { + for it := other.Content.ReverseIterator(); it.Has(); { + s.Content.PushFront(it.Next()) + } + } +} + +// WriteFile 将SRT格式的数据写入指定的文件中 +func (s *SRT) WriteFile(path string) error { + f, err := os.Create(path) + if err != nil { + //不存在对应文件夹 + if os.IsNotExist(err) { + panic("文件夹不存在:" + filepath.Dir(path)) + } + return err + } + err = s.Write(f) + err = f.Close() + return err +} + +// Write 将SRT格式的数据写入dst中 +func (s *SRT) Write(dst io.Writer) error { + //6KB的缓冲 + bufSize := 1024 * 6 + writer := bufio.NewWriterSize(dst, bufSize) + index := 1 + for it := s.Content.Iterator(); it.Has(); { + content := it.Next() + content.Index = index + index++ + _, err := writer.WriteString(content.String()) + if err != nil { + return err + } + } + return writer.Flush() +} diff --git a/srt_test.go b/srt_test.go new file mode 100644 index 0000000..921af88 --- /dev/null +++ b/srt_test.go @@ -0,0 +1,75 @@ +package ltc + +import "testing" + +func TestSRTContent_String(t *testing.T) { + type fields struct { + Index int + Start int + End int + Text string + } + tests := []struct { + name string + fields fields + want string + }{ + {"srtContent String()", fields{1, 10, 20, "test"}, + "1\n00:00:00,010 --> 00:00:00,020\ntest\n\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &SRTContent{ + Index: tt.fields.Index, + Start: tt.fields.Start, + End: tt.fields.End, + Text: tt.fields.Text, + } + if got := s.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLrcToSrt(t *testing.T) { + lrc := `[ar:artist] +[al:album] +[ti:title] +[by:author] +[00:24.83] 天涯的尽头 有谁去过 +[00:28.53] 山水优雅着 保持沉默 +[00:32.20] 我们的青春却热闹很多 +[00:35.38] 而且是谁都 不准偷 +` + content := []string{ + "天涯的尽头 有谁去过", "山水优雅着 保持沉默", "我们的青春却热闹很多", "而且是谁都 不准偷", + } + l := ParseLRC(lrc) + srt := LrcToSrt(l) + if srt.Title != "title" { + t.Errorf("SRT Title=%s, want=%s", srt.Title, "title") + } + if srt.Artist != "artist" { + t.Errorf("SRT Artist=%s, want=%s", srt.Artist, "altist") + } + index := 0 + for it := srt.Content.Iterator(); it.Has(); { + c := it.Next().Text + if c != content[index] { + t.Errorf("srt Text=%s, want=%s", c, content[index]) + } + index++ + } +} + +func TestSRT_MergeStack(t *testing.T) { + +} + +func TestSRT_MergeUp(t *testing.T) { + +} +func TestSRT_MergeBottom(t *testing.T) { + +} diff --git a/util.go b/util.go index 8d99dd5..43d026e 100644 --- a/util.go +++ b/util.go @@ -1,13 +1,22 @@ -package main +package ltc import ( "fmt" "io" + "net/http" "os" ) +const ( + CHROME_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36" +) + +var ( + client = http.Client{} +) + // Time2Millisecond 根据分,秒,毫秒 计算出对应的毫秒值 -func Time2Millisecond(m, s, ms int) int { +func time2Millisecond(m, s, ms int) int { t := m*60 + s t *= 1000 t += ms @@ -15,7 +24,7 @@ func Time2Millisecond(m, s, ms int) int { } // Millisecond2Time 根据毫秒值计算出对应的 时,分,秒,毫秒形式的时间值 -func Millisecond2Time(millisecond int) (h, m, s, ms int) { +func millisecond2Time(millisecond int) (h, m, s, ms int) { ms = millisecond % 1000 s = millisecond / 1000 diff --git a/util_test.go b/util_test.go index 4690dda..12c5b16 100644 --- a/util_test.go +++ b/util_test.go @@ -1,4 +1,4 @@ -package main +package ltc import "testing" @@ -23,7 +23,7 @@ func TestMillisecond2Time(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotH, gotM, gotS, gotMs := Millisecond2Time(tt.args.millisecond) + gotH, gotM, gotS, gotMs := millisecond2Time(tt.args.millisecond) if gotH != tt.wantH { t.Errorf("Millisecond2Time() gotH = %v, want %v", gotH, tt.wantH) } @@ -60,7 +60,7 @@ func TestTime2Millisecond(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := Time2Millisecond(tt.args.m, tt.args.s, tt.args.ms); got != tt.want { + if got := time2Millisecond(tt.args.m, tt.args.s, tt.args.ms); got != tt.want { t.Errorf("Time2Millisecond() = %v, want %v", got, tt.want) } }) @@ -69,12 +69,12 @@ func TestTime2Millisecond(t *testing.T) { func BenchmarkTime2Millisecond(b *testing.B) { for i := 0; i < b.N; i++ { - Time2Millisecond(999, 999, 999) + time2Millisecond(999, 999, 999) } } func BenchmarkMillisecond2Time(b *testing.B) { for i := 0; i < b.N; i++ { - Millisecond2Time(9999999999) + millisecond2Time(9999999999) } }