Compare commits
5 commits
45e42ce4ff
...
82e67b1a32
Author | SHA1 | Date | |
---|---|---|---|
|
82e67b1a32 | ||
|
9d031072ad | ||
|
7d5a8bdf54 | ||
|
00deeaf425 | ||
|
6875b43b78 |
6 changed files with 316 additions and 60 deletions
|
@ -1,6 +1,6 @@
|
||||||
# lrc-cli
|
# lrc-cli
|
||||||
|
|
||||||
[WIP] A CLI tool for LRC.
|
A CLI tool for LRC.
|
||||||
See [releases](https://git.owu.one/starset-mirror/lrc-cli/releases) for binaries.
|
See [releases](https://git.owu.one/starset-mirror/lrc-cli/releases) for binaries.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
122
convert.go
Normal file
122
convert.go
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func convert(args []string) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Println(CONVERT_USAGE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceFile := args[0]
|
||||||
|
targetFile := args[1]
|
||||||
|
|
||||||
|
sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".")
|
||||||
|
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
|
||||||
|
|
||||||
|
switch sourceFmt {
|
||||||
|
case "lrc":
|
||||||
|
convertLyrics(sourceFile, targetFile, targetFmt)
|
||||||
|
case "srt":
|
||||||
|
convertSRT(sourceFile, targetFile, targetFmt)
|
||||||
|
default:
|
||||||
|
fmt.Printf("unsupported source file format: %s\n", sourceFmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lrcToTxt(sourceFile, targetFile string) {
|
||||||
|
sourceLyrics, err := parseLyrics(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error parsing source lyrics file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(targetFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error creating target file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
for _, content := range sourceLyrics.Content {
|
||||||
|
fmt.Fprintln(file, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lrcToSrt(sourceFile, targetFile string) {
|
||||||
|
sourceLyrics, err := parseLyrics(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error parsing source lyrics file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(targetFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error creating target file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
for i, content := range sourceLyrics.Content {
|
||||||
|
startTime := sourceLyrics.Timeline[i]
|
||||||
|
var endTime Timestamp
|
||||||
|
if i < len(sourceLyrics.Timeline)-1 {
|
||||||
|
endTime = sourceLyrics.Timeline[i+1]
|
||||||
|
} else {
|
||||||
|
endTime = addSeconds(startTime, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(file, "%d\n", i+1)
|
||||||
|
fmt.Fprintf(file, "%s --> %s\n", formatSRTTimestamp(startTime), formatSRTTimestamp(endTime))
|
||||||
|
fmt.Fprintf(file, "%s\n\n", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func srtToLrc(sourceFile, targetFile string) {
|
||||||
|
srtEntries, err := parseSRT(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error parsing source SRT file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics := Lyrics{
|
||||||
|
Metadata: make(map[string]string),
|
||||||
|
Timeline: make([]Timestamp, len(srtEntries)),
|
||||||
|
Content: make([]string, len(srtEntries)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, entry := range srtEntries {
|
||||||
|
lyrics.Timeline[i] = entry.StartTime
|
||||||
|
lyrics.Content[i] = entry.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
err = saveLyrics(targetFile, lyrics)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error saving LRC file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func srtToTxt(sourceFile, targetFile string) {
|
||||||
|
srtEntries, err := parseSRT(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error parsing source SRT file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(targetFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error creating target file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
for _, entry := range srtEntries {
|
||||||
|
fmt.Fprintln(file, entry.Content)
|
||||||
|
}
|
||||||
|
}
|
141
util.go → lrc.go
141
util.go → lrc.go
|
@ -4,11 +4,77 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func parseTimestamp(timeStr string) (Timestamp, error) {
|
||||||
|
// remove brackets
|
||||||
|
timeStr = strings.Trim(timeStr, "[]")
|
||||||
|
|
||||||
|
parts := strings.Split(timeStr, ":")
|
||||||
|
var hours, minutes, seconds, milliseconds int
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch len(parts) {
|
||||||
|
case 2: // minutes:seconds.milliseconds
|
||||||
|
minutes, err = strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return Timestamp{}, err
|
||||||
|
}
|
||||||
|
secParts := strings.Split(parts[1], ".")
|
||||||
|
seconds, err = strconv.Atoi(secParts[0])
|
||||||
|
if err != nil {
|
||||||
|
return Timestamp{}, err
|
||||||
|
}
|
||||||
|
if len(secParts) > 1 {
|
||||||
|
milliseconds, err = strconv.Atoi(secParts[1])
|
||||||
|
if err != nil {
|
||||||
|
return Timestamp{}, err
|
||||||
|
}
|
||||||
|
// adjust milliseconds based on the number of digits
|
||||||
|
switch len(secParts[1]) {
|
||||||
|
case 1:
|
||||||
|
milliseconds *= 100
|
||||||
|
case 2:
|
||||||
|
milliseconds *= 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 3: // hours:minutes:seconds.milliseconds
|
||||||
|
hours, err = strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return Timestamp{}, err
|
||||||
|
}
|
||||||
|
minutes, err = strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return Timestamp{}, err
|
||||||
|
}
|
||||||
|
secParts := strings.Split(parts[2], ".")
|
||||||
|
seconds, err = strconv.Atoi(secParts[0])
|
||||||
|
if err != nil {
|
||||||
|
return Timestamp{}, err
|
||||||
|
}
|
||||||
|
if len(secParts) > 1 {
|
||||||
|
milliseconds, err = strconv.Atoi(secParts[1])
|
||||||
|
if err != nil {
|
||||||
|
return Timestamp{}, err
|
||||||
|
}
|
||||||
|
// adjust milliseconds based on the number of digits
|
||||||
|
switch len(secParts[1]) {
|
||||||
|
case 1:
|
||||||
|
milliseconds *= 100
|
||||||
|
case 2:
|
||||||
|
milliseconds *= 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Timestamp{}, fmt.Errorf("invalid timestamp format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timestamp{Hours: hours, Minutes: minutes, Seconds: seconds, Milliseconds: milliseconds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseLyrics(filePath string) (Lyrics, error) {
|
func parseLyrics(filePath string) (Lyrics, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -27,9 +93,13 @@ func parseLyrics(filePath string) (Lyrics, error) {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") {
|
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") {
|
||||||
if timeLineRegex.MatchString(line) {
|
if timeLineRegex.MatchString(line) {
|
||||||
// Timeline Tag
|
// Timeline
|
||||||
time := timeLineRegex.FindString(line)
|
timeStr := timeLineRegex.FindString(line)
|
||||||
lyrics.Timeline = append(lyrics.Timeline, time)
|
timestamp, err := parseTimestamp(timeStr)
|
||||||
|
if err != nil {
|
||||||
|
return Lyrics{}, err
|
||||||
|
}
|
||||||
|
lyrics.Timeline = append(lyrics.Timeline, timestamp)
|
||||||
// Content
|
// Content
|
||||||
content := timeLineRegex.ReplaceAllString(line, "")
|
content := timeLineRegex.ReplaceAllString(line, "")
|
||||||
lyrics.Content = append(lyrics.Content, strings.TrimSpace(content))
|
lyrics.Content = append(lyrics.Content, strings.TrimSpace(content))
|
||||||
|
@ -64,7 +134,7 @@ func saveLyrics(filePath string, lyrics Lyrics) error {
|
||||||
|
|
||||||
// Write timeline and content
|
// Write timeline and content
|
||||||
for i := 0; i < len(lyrics.Timeline); i++ {
|
for i := 0; i < len(lyrics.Timeline); i++ {
|
||||||
fmt.Fprintf(file, "%s %s\n", lyrics.Timeline[i], lyrics.Content[i])
|
fmt.Fprintf(file, "%s %s\n", timestampToString(lyrics.Timeline[i]), lyrics.Content[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -91,16 +161,14 @@ func syncLyrics(args []string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync timeline
|
|
||||||
if len(sourceLyrics.Timeline) != len(targetLyrics.Timeline) {
|
|
||||||
fmt.Println("Warning: Timeline length mismatch")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
minLength := len(sourceLyrics.Timeline)
|
minLength := len(sourceLyrics.Timeline)
|
||||||
if len(targetLyrics.Timeline) < minLength {
|
if len(targetLyrics.Timeline) < minLength {
|
||||||
minLength = len(targetLyrics.Timeline)
|
minLength = len(targetLyrics.Timeline)
|
||||||
|
fmt.Printf("Warning: Timeline length mismatch. Source: %d lines, Target: %d lines. Will sync the first %d lines.\n",
|
||||||
|
len(sourceLyrics.Timeline), len(targetLyrics.Timeline), minLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync the timeline
|
||||||
for i := 0; i < minLength; i++ {
|
for i := 0; i < minLength; i++ {
|
||||||
targetLyrics.Timeline[i] = sourceLyrics.Timeline[i]
|
targetLyrics.Timeline[i] = sourceLyrics.Timeline[i]
|
||||||
}
|
}
|
||||||
|
@ -114,39 +182,14 @@ func syncLyrics(args []string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// func printLyricsInfo(lyrics Lyrics) {
|
func convertLyrics(sourceFile, targetFile, targetFmt string) {
|
||||||
// fmt.Println("Metadata:")
|
|
||||||
// for key, value := range lyrics.Metadata {
|
|
||||||
// fmt.Printf("%s: %s\n", key, value)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// fmt.Println("\nTimeline:")
|
|
||||||
// for _, time := range lyrics.Timeline {
|
|
||||||
// fmt.Println(time)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// fmt.Println("\nLyrics Content:")
|
|
||||||
// for _, content := range lyrics.Content {
|
|
||||||
// fmt.Println(content)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
func convertLyrics(args []string) {
|
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.Println(CONVERT_USAGE)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceFile := args[0]
|
|
||||||
targetFile := args[1]
|
|
||||||
|
|
||||||
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
|
|
||||||
|
|
||||||
switch targetFmt {
|
switch targetFmt {
|
||||||
case "txt":
|
case "txt":
|
||||||
lrcToTxt(sourceFile, targetFile)
|
lrcToTxt(sourceFile, targetFile)
|
||||||
|
case "srt":
|
||||||
|
lrcToSrt(sourceFile, targetFile)
|
||||||
default:
|
default:
|
||||||
fmt.Println("Unsupported target format:", targetFmt)
|
fmt.Printf("unsupported target format: %s\n", targetFmt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,21 +216,9 @@ func fmtLyrics(args []string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func lrcToTxt(sourceFile, targetFile string) {
|
func timestampToString(ts Timestamp) string {
|
||||||
sourceLyrics, err := parseLyrics(sourceFile)
|
if ts.Hours > 0 {
|
||||||
if err != nil {
|
return fmt.Sprintf("[%02d:%02d:%02d.%03d]", ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds)
|
||||||
fmt.Println("Error parsing source lyrics file:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Create(targetFile)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error creating target file:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
for _, content := range sourceLyrics.Content {
|
|
||||||
fmt.Fprintln(file, content)
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("[%02d:%02d.%03d]", ts.Minutes, ts.Seconds, ts.Milliseconds)
|
||||||
}
|
}
|
2
main.go
2
main.go
|
@ -15,7 +15,7 @@ func main() {
|
||||||
case "sync":
|
case "sync":
|
||||||
syncLyrics(os.Args[2:])
|
syncLyrics(os.Args[2:])
|
||||||
case "convert":
|
case "convert":
|
||||||
convertLyrics(os.Args[2:])
|
convert(os.Args[2:])
|
||||||
case "fmt":
|
case "fmt":
|
||||||
fmtLyrics(os.Args[2:])
|
fmtLyrics(os.Args[2:])
|
||||||
case "version":
|
case "version":
|
||||||
|
|
23
model.go
23
model.go
|
@ -1,22 +1,39 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
type Timestamp struct {
|
||||||
|
Hours int
|
||||||
|
Minutes int
|
||||||
|
Seconds int
|
||||||
|
Milliseconds int
|
||||||
|
}
|
||||||
|
|
||||||
type Lyrics struct {
|
type Lyrics struct {
|
||||||
Metadata map[string]string
|
Metadata map[string]string
|
||||||
Timeline []string
|
Timeline []Timestamp
|
||||||
Content []string
|
Content []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SRTEntry struct {
|
||||||
|
Number int
|
||||||
|
StartTime Timestamp
|
||||||
|
EndTime Timestamp
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VERSION = "0.2.0"
|
VERSION = "0.3.0"
|
||||||
USAGE = `Usage: lyc-cli [command] [options]
|
USAGE = `Usage: lyc-cli [command] [options]
|
||||||
Commands:
|
Commands:
|
||||||
sync Synchronize timeline of two lyrics files
|
sync Synchronize timeline of two lyrics files
|
||||||
convert Convert lyrics file to another format
|
convert Convert lyrics file to another format
|
||||||
|
fmt Format lyrics file
|
||||||
help Show help`
|
help Show help`
|
||||||
|
|
||||||
SYNC_USAGE = `Usage: lyc-cli sync <source> <target>`
|
SYNC_USAGE = `Usage: lyc-cli sync <source> <target>`
|
||||||
CONVERT_USAGE = `Usage: lyc-cli convert <source> <target>
|
CONVERT_USAGE = `Usage: lyc-cli convert <source> <target>
|
||||||
Note:
|
Note:
|
||||||
Target format is determined by file extension. Supported formats:
|
Target format is determined by file extension. Supported formats:
|
||||||
.txt Plain text format(No meta/timeline tags)`
|
.txt Plain text format(No meta/timeline tags, only support as target format)
|
||||||
|
.srt SubRip Subtitle format
|
||||||
|
.lrc LRC format`
|
||||||
)
|
)
|
||||||
|
|
86
srt.go
Normal file
86
srt.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseSRT(filePath string) ([]SRTEntry, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
var entries []SRTEntry
|
||||||
|
var currentEntry SRTEntry
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
if currentEntry.Number != 0 {
|
||||||
|
entries = append(entries, currentEntry)
|
||||||
|
currentEntry = SRTEntry{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentEntry.Number == 0 {
|
||||||
|
currentEntry.Number, _ = strconv.Atoi(line)
|
||||||
|
} else if currentEntry.StartTime.Hours == 0 {
|
||||||
|
times := strings.Split(line, " --> ")
|
||||||
|
currentEntry.StartTime = parseSRTTimestamp(times[0])
|
||||||
|
currentEntry.EndTime = parseSRTTimestamp(times[1])
|
||||||
|
} else {
|
||||||
|
currentEntry.Content += line + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentEntry.Number != 0 {
|
||||||
|
entries = append(entries, currentEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertSRT(sourceFile, targetFile, targetFmt string) {
|
||||||
|
switch targetFmt {
|
||||||
|
case "txt":
|
||||||
|
srtToTxt(sourceFile, targetFile)
|
||||||
|
case "lrc":
|
||||||
|
srtToLrc(sourceFile, targetFile)
|
||||||
|
default:
|
||||||
|
fmt.Printf("unsupported target format: %s\n", targetFmt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSRTTimestamp(ts Timestamp) string {
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d,%03d", ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSRTTimestamp(timeStr string) Timestamp {
|
||||||
|
t, _ := time.Parse("15:04:05,000", timeStr)
|
||||||
|
return Timestamp{
|
||||||
|
Hours: t.Hour(),
|
||||||
|
Minutes: t.Minute(),
|
||||||
|
Seconds: t.Second(),
|
||||||
|
Milliseconds: t.Nanosecond() / 1e6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// basically for the last line of lrc
|
||||||
|
func addSeconds(ts Timestamp, seconds int) Timestamp {
|
||||||
|
t := time.Date(0, 1, 1, ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds*1e6, time.UTC)
|
||||||
|
t = t.Add(time.Duration(seconds) * time.Second)
|
||||||
|
return Timestamp{
|
||||||
|
Hours: t.Hour(),
|
||||||
|
Minutes: t.Minute(),
|
||||||
|
Seconds: t.Second(),
|
||||||
|
Milliseconds: t.Nanosecond() / 1e6,
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue