Compare commits

...

18 commits
v0.1.1 ... main

Author SHA1 Message Date
珈乐不甜不要钱
f044d36339 Merge branch 'master' of github.com:Hami-Lemon/ltc 2022-06-02 15:46:48 +08:00
珈乐不甜不要钱
425e354bca update README 2022-06-02 15:46:25 +08:00
珈乐不甜不要钱
b973ecb562
Update README.md 2022-04-04 13:36:32 +08:00
Hami Lemon
dc2e111c14 修改README 2022-04-04 13:35:29 +08:00
Hami Lemon
7212aa3220 修改github action 2022-03-30 11:01:30 +08:00
Hami Lemon
76d1e8215d 修改github action的go version 2022-03-30 10:58:03 +08:00
Hami Lemon
72607e927c 修改github action的go version 2022-03-30 10:54:32 +08:00
Hami Lemon
dc8033dd53 删除build.bat 2022-03-30 10:43:09 +08:00
Hami Lemon
eba676b8de 支持ass转换 2022-03-30 10:41:28 +08:00
Hami Lemon
a29d2c057e 修改模块名: lrc2lts => ltc (LrcToCaptions) 2022-03-29 19:47:26 +08:00
Hami Lemon
3e2af31678 添加测试用例 2022-03-29 11:16:04 +08:00
Hami Lemon
a4ea096e3d 修改版本号 2022-03-28 20:40:24 +08:00
Hami Lemon
c381852b49 修复srt序号错误以及模式3无译文的问题 2022-03-28 20:37:37 +08:00
Hami Lemon
8b57abe8ee 优化算法 2022-03-28 20:04:51 +08:00
Hami Lemon
8891c44211 Merge branch 'master' into dev 2022-03-28 18:29:12 +08:00
Hami Lemon
d708df2de0 修改gitignore 2022-03-28 18:28:44 +08:00
Hami Lemon
c9b1b38c03 重构项目,调整了项目结构 2022-03-28 18:26:40 +08:00
Hami Lemon
5f49bde3ae 实现一个支持泛型的链表 2022-03-28 17:38:09 +08:00
28 changed files with 1570 additions and 432 deletions

View file

@ -13,25 +13,14 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Go - name: Setup Go environment
uses: actions/setup-go@v2 uses: actions/setup-go@v3.0.0
with: with:
go-version: 1.17 go-version: '>=1.18'
check-latest: true check-latest: true
- name: Build - name: Build
run: go build -v ./... 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 - name: Test
run: go test -run=Test run: go test -run=Test

5
.gitignore vendored
View file

@ -87,6 +87,7 @@ fabric.properties
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/
/example/*.srt *.srt
/example/*.lrc *.lrc
*.ass
/temp /temp

1
.idea/.name generated Normal file
View file

@ -0,0 +1 @@
ltc

View file

@ -2,9 +2,7 @@
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" /> <component name="Go" enabled="true" />
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
</content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>

8
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SwUserDefinedSpecifications">
<option name="specTypeByUrl">
<map />
</option>
</component>
</project>

2
.idea/modules.xml generated
View file

@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lrc2srt.iml" filepath="$PROJECT_DIR$/.idea/lrc2srt.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/ltc.iml" filepath="$PROJECT_DIR$/.idea/ltc.iml" />
</modules> </modules>
</component> </component>
</project> </project>

View file

@ -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) [![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] lrc文件转换成srt文件
- [x] 从网易云音乐或QQ音乐上获取歌词并转换成srt文件 - [x] lrc文件转换成ass文件
- [x] 从网易云音乐或QQ音乐上下载歌词 - [x] 从网易云音乐或QQ音乐上获取歌词并转换。
- [x] 从网易云音乐或QQ音乐上下载歌词。
## 下载 ## 下载
@ -16,21 +17,25 @@
## 开始使用 ## 开始使用
``` ```text
Usage: LrcToCaptions(ltc) 将LRC歌词文件转换成字幕文件。
D:\ProgrameStudy\lrc2srt\lts.exe [OPTIONS] ltc version: "0.3.4" (build 2022.03.30)
Application Options: 用法ltc [options] OutputFile
-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 获取版本信息
Help Options: options:
-h, --help Show this help message
-d 设置该选项时,只下载歌词,而无需转换。
-f value
转换成的字幕文件格式可选值ass(默认),srt
-i string
歌词来源可以是歌词对应的歌曲id也可以是歌词文件
-m value
设置歌词原文和译文的合并模式可选值1(默认),2,3。
-s string
选择从网易云还是QQ音乐上获取歌词可选值163(默认)qq。 (default "163")
-v 获取当前程序版本信息。
-h 显示帮助信息。
``` ```
### 获取歌曲id ### 获取歌曲id
@ -145,3 +150,18 @@ lts -i 003FJlVU1rxjv8 -m 2 -s qq "ふわふわ时间.srt"
## 结束时间处理策略 ## 结束时间处理策略
因为在LRC文件中并不包含一句歌词的结束时间所以在转换成SRT文件时处理策略为**一句歌词的结束时间为下一句歌词的开始时间,最后一句歌词的结束时间为其`开始时间+10秒`**,所以在打轴时,对进入间奏的地方应该手动调整歌词的结束时间。 因为在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 .
```

125
ass.go Normal file
View file

@ -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
}

View file

@ -1,3 +0,0 @@
echo off
@REM forceposix 表示在windows上参数也为linux风格即以“-”开头
go build -tags="forceposix" -ldflags "-s -w" -o lts.exe .

View file

@ -1,11 +1,10 @@
package main package ltc
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
) )
type CloudLyricBase struct { type CloudLyricBase struct {
@ -38,15 +37,15 @@ func Get163Lyric(id string) (lyric, tLyric string) {
req, _ := http.NewRequest("GET", api, nil) req, _ := http.NewRequest("GET", api, nil)
//必须设置Referer,否则会请求失败 //必须设置Referer,否则会请求失败
req.Header.Add("Referer", "https://music.163.com") 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) resp, err := client.Do(req)
if err != nil { if err != nil {
fmt.Printf("网络错误:%v\n", err) fmt.Printf("网络错误:%v\n", err)
os.Exit(1) panic("网络异常,获取失败。")
} }
if resp == nil || resp.StatusCode != http.StatusOK { if resp == nil || resp.StatusCode != http.StatusOK {
fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode) fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode)
os.Exit(1) panic("获取失败,未能正确获取到数据")
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -54,7 +53,7 @@ func Get163Lyric(id string) (lyric, tLyric string) {
err = json.NewDecoder(resp.Body).Decode(&cloudLyric) err = json.NewDecoder(resp.Body).Decode(&cloudLyric)
if cloudLyric.Sgc { if cloudLyric.Sgc {
fmt.Printf("获取歌词失败,返回的结果为:%+v请检查id是否正确\n", cloudLyric) fmt.Printf("获取歌词失败,返回的结果为:%+v请检查id是否正确\n", cloudLyric)
os.Exit(1) panic("id错误获取歌词失败")
} }
return cloudLyric.Lrc.Lyric, cloudLyric.TLyric.Lyric return cloudLyric.Lrc.Lyric, cloudLyric.TLyric.Lyric
} }

22
cloudlyric_test.go Normal file
View file

@ -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)
}
})
}
}

268
glist/linkedlist.go Normal file
View file

@ -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
}

209
glist/linkedlist_test.go Normal file
View file

@ -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)
}
})
}
}

50
glist/list.go Normal file
View file

@ -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
}

8
go.mod
View file

@ -1,7 +1,3 @@
module github.com/hami_lemon/lrc2srt module github.com/Hami-Lemon/ltc
go 1.17 go 1.18
require github.com/jessevdk/go-flags v1.5.0
require golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 // indirect

4
go.sum
View file

@ -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=

128
lrc.go Normal file
View file

@ -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
}

68
lrc_test.go Normal file
View file

@ -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)
}
})
}
}

196
lrctocaptions/flag.go Normal file
View file

@ -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)
}

122
lrctocaptions/ltc.go Normal file
View file

@ -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("")
}
}

337
lts.go
View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -1,4 +1,4 @@
package main package ltc
import ( import (
"compress/gzip" "compress/gzip"
@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
) )
/** /**
@ -43,17 +42,17 @@ func GetQQLyric(id string) (lyric, tLyric string) {
req, _ := http.NewRequest("GET", api, nil) req, _ := http.NewRequest("GET", api, nil)
//必须设置Referer,否则会请求失败 //必须设置Referer,否则会请求失败
req.Header.Add("Referer", "https://y.qq.com") 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") req.Header.Add("accept-encoding", "gzip")
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
fmt.Printf("网络错误:%v\n", err) fmt.Printf("网络错误:%v\n", err)
os.Exit(1) panic("网络异常,请求失败。")
} }
if resp == nil || resp.StatusCode != http.StatusOK { if resp == nil || resp.StatusCode != http.StatusOK {
fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode) fmt.Printf("网络请求失败,状态码为:%d\n", resp.StatusCode)
os.Exit(1) panic("获取失败,未能正确获取到数据")
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -63,7 +62,7 @@ func GetQQLyric(id string) (lyric, tLyric string) {
err = json.NewDecoder(reader).Decode(&qqLyric) err = json.NewDecoder(reader).Decode(&qqLyric)
if qqLyric.RetCode != 0 { if qqLyric.RetCode != 0 {
fmt.Printf("获取歌词失败,返回的结果为:%+v请检查id是否正确\n", qqLyric) fmt.Printf("获取歌词失败,返回的结果为:%+v请检查id是否正确\n", qqLyric)
os.Exit(1) panic("id错误获取歌词失败。")
} }
return qqLyric.Lyric, qqLyric.Trans return qqLyric.Lyric, qqLyric.Trans
} }

22
qqlyric_test.go Normal file
View file

@ -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)
}
})
}
}

202
srt.go Normal file
View file

@ -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()
}

75
srt_test.go Normal file
View file

@ -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) {
}

15
util.go
View file

@ -1,13 +1,22 @@
package main package ltc
import ( import (
"fmt" "fmt"
"io" "io"
"net/http"
"os" "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 根据分,秒,毫秒 计算出对应的毫秒值 // Time2Millisecond 根据分,秒,毫秒 计算出对应的毫秒值
func Time2Millisecond(m, s, ms int) int { func time2Millisecond(m, s, ms int) int {
t := m*60 + s t := m*60 + s
t *= 1000 t *= 1000
t += ms t += ms
@ -15,7 +24,7 @@ func Time2Millisecond(m, s, ms int) int {
} }
// Millisecond2Time 根据毫秒值计算出对应的 时,分,秒,毫秒形式的时间值 // Millisecond2Time 根据毫秒值计算出对应的 时,分,秒,毫秒形式的时间值
func Millisecond2Time(millisecond int) (h, m, s, ms int) { func millisecond2Time(millisecond int) (h, m, s, ms int) {
ms = millisecond % 1000 ms = millisecond % 1000
s = millisecond / 1000 s = millisecond / 1000

View file

@ -1,4 +1,4 @@
package main package ltc
import "testing" import "testing"
@ -23,7 +23,7 @@ func TestMillisecond2Time(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { if gotH != tt.wantH {
t.Errorf("Millisecond2Time() gotH = %v, want %v", 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) t.Errorf("Time2Millisecond() = %v, want %v", got, tt.want)
} }
}) })
@ -69,12 +69,12 @@ func TestTime2Millisecond(t *testing.T) {
func BenchmarkTime2Millisecond(b *testing.B) { func BenchmarkTime2Millisecond(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
Time2Millisecond(999, 999, 999) time2Millisecond(999, 999, 999)
} }
} }
func BenchmarkMillisecond2Time(b *testing.B) { func BenchmarkMillisecond2Time(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
Millisecond2Time(9999999999) millisecond2Time(9999999999)
} }
} }