Compare commits

...

5 commits

Author SHA1 Message Date
CDN18
82e67b1a32
chore: not WIP anymore 2024-09-27 21:04:33 +08:00
CDN18
9d031072ad
chore: bump version 2024-09-27 21:00:46 +08:00
CDN18
7d5a8bdf54
feat: support srt 2024-09-27 20:59:59 +08:00
CDN18
00deeaf425
chore: prepare for srt support + force sync lrc timestamp if timeline length mismatch 2024-09-27 20:39:48 +08:00
CDN18
6875b43b78
refactor: parse Timeline as Timestamp[] 2024-09-27 20:28:22 +08:00
6 changed files with 316 additions and 60 deletions

View file

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

View file

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

View file

@ -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":

View file

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