Compare commits

..

2 commits

Author SHA1 Message Date
CDN
3af537c3dd
chore: bump version
All checks were successful
Build and Release / Build (darwin-arm64) (push) Successful in 21s
Build and Release / Build (linux-arm64) (push) Successful in 23s
Build and Release / Build (darwin-amd64) (push) Successful in 24s
Build and Release / Build (windows-arm64) (push) Successful in 20s
Build and Release / Build (linux-amd64) (push) Successful in 32s
Build and Release / Build (windows-amd64) (push) Successful in 25s
Build and Release / Create Release (push) Successful in 22s
2025-04-23 08:02:00 +08:00
CDN
9b0e2ed6dc
refactor 2025-04-23 08:01:13 +08:00
15 changed files with 693 additions and 540 deletions

6
.gitignore vendored
View file

@ -1,2 +1,4 @@
release/
release.ps1
sub-cli
sub-cli.exe
.DS_Store

View file

@ -1,14 +1,13 @@
# lrc-cli
# sub-cli
A CLI tool for LRC.
See [releases](https://git.owu.one/starset-mirror/lrc-cli/releases) for binaries.
A CLI tool for subtitle.
See [releases](https://git.owu.one/starset-mirror/sub-cli/releases) for binaries.
## Usage
```shell
./lrc-cli --help
./sub-cli --help
```
## License
AGPL-3.0

80
cmd/root.go Normal file
View file

@ -0,0 +1,80 @@
package cmd
import (
"fmt"
"os"
"sub-cli/internal/config"
"sub-cli/internal/converter"
"sub-cli/internal/formatter"
"sub-cli/internal/sync"
)
// Execute runs the main CLI application
func Execute() {
// parse args
if len(os.Args) < 2 {
fmt.Println(config.Usage)
return
}
switch os.Args[1] {
case "sync":
handleSync(os.Args[2:])
case "convert":
handleConvert(os.Args[2:])
case "fmt":
handleFormat(os.Args[2:])
case "version":
fmt.Printf("sub-cli version %s\n", config.Version)
case "help":
fmt.Println(config.Usage)
default:
fmt.Println("Unknown command")
fmt.Println(config.Usage)
}
}
// handleSync handles the sync command
func handleSync(args []string) {
if len(args) < 2 {
fmt.Println(config.SyncUsage)
return
}
sourceFile := args[0]
targetFile := args[1]
if err := sync.SyncLyrics(sourceFile, targetFile); err != nil {
fmt.Printf("Error: %v\n", err)
}
}
// handleConvert handles the convert command
func handleConvert(args []string) {
if len(args) < 2 {
fmt.Println(config.ConvertUsage)
return
}
sourceFile := args[0]
targetFile := args[1]
if err := converter.Convert(sourceFile, targetFile); err != nil {
fmt.Printf("Error: %v\n", err)
}
}
// handleFormat handles the fmt command
func handleFormat(args []string) {
if len(args) < 1 {
fmt.Println("Usage: sub-cli fmt <file>")
return
}
filePath := args[0]
if err := formatter.Format(filePath); err != nil {
fmt.Printf("Error: %v\n", err)
}
}

View file

@ -1,128 +0,0 @@
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)),
}
// Add default metadata
title := strings.TrimSuffix(filepath.Base(targetFile), filepath.Ext(targetFile))
lyrics.Metadata["ti"] = title
lyrics.Metadata["ar"] = ""
lyrics.Metadata["al"] = ""
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

@ -0,0 +1,23 @@
package config
// Version stores the current application version
const Version = "0.4.0"
// Usage stores the general usage information
const Usage = `Usage: sub-cli [command] [options]
Commands:
sync Synchronize timeline of two lyrics files
convert Convert lyrics file to another format
fmt Format lyrics file
help Show help`
// SyncUsage stores the usage information for the sync command
const SyncUsage = `Usage: sub-cli sync <source> <target>`
// ConvertUsage stores the usage information for the convert command
const ConvertUsage = `Usage: sub-cli convert <source> <target>
Note:
Target format is determined by file extension. Supported formats:
.txt Plain text format(No meta/timeline tags, only support as target format)
.srt SubRip Subtitle format
.lrc LRC format`

View file

@ -0,0 +1,137 @@
package converter
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sub-cli/internal/format/lrc"
"sub-cli/internal/format/srt"
"sub-cli/internal/model"
)
// ErrUnsupportedFormat is returned when trying to convert to/from an unsupported format
var ErrUnsupportedFormat = errors.New("unsupported format")
// Convert converts a file from one format to another
func Convert(sourceFile, targetFile string) error {
sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".")
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
switch sourceFmt {
case "lrc":
return convertFromLRC(sourceFile, targetFile, targetFmt)
case "srt":
return convertFromSRT(sourceFile, targetFile, targetFmt)
default:
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFmt)
}
}
// convertFromLRC converts an LRC file to another format
func convertFromLRC(sourceFile, targetFile, targetFmt string) error {
sourceLyrics, err := lrc.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source LRC file: %w", err)
}
switch targetFmt {
case "txt":
return lrcToTxt(sourceLyrics, targetFile)
case "srt":
return lrcToSRT(sourceLyrics, targetFile)
case "lrc":
return lrc.Generate(sourceLyrics, targetFile)
default:
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, targetFmt)
}
}
// convertFromSRT converts an SRT file to another format
func convertFromSRT(sourceFile, targetFile, targetFmt string) error {
entries, err := srt.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source SRT file: %w", err)
}
switch targetFmt {
case "txt":
return srtToTxt(entries, targetFile)
case "lrc":
lyrics := srt.ConvertToLyrics(entries)
return lrc.Generate(lyrics, targetFile)
case "srt":
return srt.Generate(entries, targetFile)
default:
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, targetFmt)
}
}
// lrcToTxt converts LRC lyrics to a plain text file
func lrcToTxt(lyrics model.Lyrics, targetFile string) error {
file, err := os.Create(targetFile)
if err != nil {
return fmt.Errorf("error creating target file: %w", err)
}
defer file.Close()
for _, content := range lyrics.Content {
if _, err := fmt.Fprintln(file, content); err != nil {
return err
}
}
return nil
}
// lrcToSRT converts LRC lyrics to an SRT file
func lrcToSRT(lyrics model.Lyrics, targetFile string) error {
var entries []model.SRTEntry
for i, content := range lyrics.Content {
if i >= len(lyrics.Timeline) {
break
}
startTime := lyrics.Timeline[i]
endTime := startTime
// If there's a next timeline entry, use it for end time
// Otherwise add a few seconds to the start time
if i+1 < len(lyrics.Timeline) {
endTime = lyrics.Timeline[i+1]
} else {
endTime.Seconds += 3
}
entry := model.SRTEntry{
Number: i + 1,
StartTime: startTime,
EndTime: endTime,
Content: content,
}
entries = append(entries, entry)
}
return srt.Generate(entries, targetFile)
}
// srtToTxt converts SRT entries to a plain text file
func srtToTxt(entries []model.SRTEntry, targetFile string) error {
file, err := os.Create(targetFile)
if err != nil {
return fmt.Errorf("error creating target file: %w", err)
}
defer file.Close()
for _, entry := range entries {
if _, err := fmt.Fprintln(file, entry.Content); err != nil {
return err
}
}
return nil
}

182
internal/format/lrc/lrc.go Normal file
View file

@ -0,0 +1,182 @@
package lrc
import (
"bufio"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"sub-cli/internal/model"
)
// Parse parses an LRC file and returns a Lyrics struct
func Parse(filePath string) (model.Lyrics, error) {
lyrics := model.Lyrics{
Metadata: make(map[string]string),
}
file, err := os.Open(filePath)
if err != nil {
return lyrics, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
metadataRegex := regexp.MustCompile(`\[([\w:]+):(.*?)\]`)
timestampRegex := regexp.MustCompile(`\[(\d+:\d+(?:\.\d+)?)\]`)
for scanner.Scan() {
line := scanner.Text()
// Extract metadata
metadataMatches := metadataRegex.FindAllStringSubmatch(line, -1)
for _, match := range metadataMatches {
if len(match) >= 3 {
key := match[1]
value := match[2]
lyrics.Metadata[key] = value
}
}
// Extract timestamp and content
timestampMatches := timestampRegex.FindAllStringSubmatch(line, -1)
if len(timestampMatches) > 0 {
var timestamps []model.Timestamp
lineContent := line
for _, match := range timestampMatches {
if len(match) >= 2 {
timestamp, err := ParseTimestamp(match[1])
if err == nil {
timestamps = append(timestamps, timestamp)
}
lineContent = strings.Replace(lineContent, match[0], "", 1)
}
}
lineContent = strings.TrimSpace(lineContent)
if lineContent != "" {
for range timestamps {
lyrics.Timeline = append(lyrics.Timeline, timestamps...)
lyrics.Content = append(lyrics.Content, lineContent)
}
}
}
}
return lyrics, nil
}
// ParseTimestamp parses an LRC timestamp string into a Timestamp struct
func ParseTimestamp(timeStr string) (model.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 model.Timestamp{}, err
}
secParts := strings.Split(parts[1], ".")
seconds, err = strconv.Atoi(secParts[0])
if err != nil {
return model.Timestamp{}, err
}
if len(secParts) > 1 {
milliseconds, err = strconv.Atoi(secParts[1])
if err != nil {
return model.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 model.Timestamp{}, err
}
minutes, err = strconv.Atoi(parts[1])
if err != nil {
return model.Timestamp{}, err
}
secParts := strings.Split(parts[2], ".")
seconds, err = strconv.Atoi(secParts[0])
if err != nil {
return model.Timestamp{}, err
}
if len(secParts) > 1 {
milliseconds, err = strconv.Atoi(secParts[1])
if err != nil {
return model.Timestamp{}, err
}
// adjust milliseconds based on the number of digits
switch len(secParts[1]) {
case 1:
milliseconds *= 100
case 2:
milliseconds *= 10
}
}
default:
return model.Timestamp{}, fmt.Errorf("invalid timestamp format: %s", timeStr)
}
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}, nil
}
// Generate generates an LRC file from a Lyrics struct
func Generate(lyrics model.Lyrics, filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
// Write metadata
for key, value := range lyrics.Metadata {
fmt.Fprintf(file, "[%s:%s]\n", key, value)
}
// Write content with timestamps
for i, content := range lyrics.Content {
if i < len(lyrics.Timeline) {
timestamp := lyrics.Timeline[i]
fmt.Fprintf(file, "[%02d:%02d.%03d]%s\n",
timestamp.Minutes,
timestamp.Seconds,
timestamp.Milliseconds,
content)
} else {
fmt.Fprintln(file, content)
}
}
return nil
}
// Format formats an LRC file
func Format(filePath string) error {
lyrics, err := Parse(filePath)
if err != nil {
return err
}
return Generate(lyrics, filePath)
}

137
internal/format/srt/srt.go Normal file
View file

@ -0,0 +1,137 @@
package srt
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"time"
"sub-cli/internal/model"
)
// Parse parses an SRT file and returns a slice of SRTEntries
func Parse(filePath string) ([]model.SRTEntry, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var entries []model.SRTEntry
var currentEntry model.SRTEntry
var isContent bool
var contentBuffer strings.Builder
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
if currentEntry.Number != 0 {
currentEntry.Content = contentBuffer.String()
entries = append(entries, currentEntry)
currentEntry = model.SRTEntry{}
isContent = false
contentBuffer.Reset()
}
continue
}
if currentEntry.Number == 0 {
currentEntry.Number, _ = strconv.Atoi(line)
} else if isEntryTimeStampUnset(currentEntry) {
times := strings.Split(line, " --> ")
if len(times) == 2 {
currentEntry.StartTime = parseSRTTimestamp(times[0])
currentEntry.EndTime = parseSRTTimestamp(times[1])
isContent = true
}
} else if isContent {
if contentBuffer.Len() > 0 {
contentBuffer.WriteString("\n")
}
contentBuffer.WriteString(line)
}
}
// Don't forget the last entry
if currentEntry.Number != 0 && contentBuffer.Len() > 0 {
currentEntry.Content = contentBuffer.String()
entries = append(entries, currentEntry)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return entries, nil
}
// isEntryTimeStampUnset checks if timestamp is unset
func isEntryTimeStampUnset(entry model.SRTEntry) bool {
return entry.StartTime.Hours == 0 &&
entry.StartTime.Minutes == 0 &&
entry.StartTime.Seconds == 0 &&
entry.StartTime.Milliseconds == 0
}
// parseSRTTimestamp parses an SRT timestamp string into a Timestamp struct
func parseSRTTimestamp(timeStr string) model.Timestamp {
timeStr = strings.Replace(timeStr, ",", ".", 1)
format := "15:04:05.000"
t, err := time.Parse(format, timeStr)
if err != nil {
return model.Timestamp{}
}
return model.Timestamp{
Hours: t.Hour(),
Minutes: t.Minute(),
Seconds: t.Second(),
Milliseconds: t.Nanosecond() / 1000000,
}
}
// Generate generates an SRT file from a slice of SRTEntries
func Generate(entries []model.SRTEntry, filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
for _, entry := range entries {
fmt.Fprintf(file, "%d\n", entry.Number)
fmt.Fprintf(file, "%s --> %s\n",
formatSRTTimestamp(entry.StartTime),
formatSRTTimestamp(entry.EndTime))
fmt.Fprintf(file, "%s\n\n", entry.Content)
}
return nil
}
// formatSRTTimestamp formats a Timestamp struct as an SRT timestamp string
func formatSRTTimestamp(ts model.Timestamp) string {
return fmt.Sprintf("%02d:%02d:%02d,%03d",
ts.Hours,
ts.Minutes,
ts.Seconds,
ts.Milliseconds)
}
// ConvertToLyrics converts SRT entries to a Lyrics structure
func ConvertToLyrics(entries []model.SRTEntry) model.Lyrics {
lyrics := model.Lyrics{
Metadata: make(map[string]string),
}
for _, entry := range entries {
lyrics.Timeline = append(lyrics.Timeline, entry.StartTime)
lyrics.Content = append(lyrics.Content, entry.Content)
}
return lyrics
}

View file

@ -0,0 +1,21 @@
package formatter
import (
"fmt"
"path/filepath"
"strings"
"sub-cli/internal/format/lrc"
)
// Format formats a subtitle file to ensure consistent formatting
func Format(filePath string) error {
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
switch ext {
case "lrc":
return lrc.Format(filePath)
default:
return fmt.Errorf("unsupported format for formatting: %s", ext)
}
}

24
internal/model/model.go Normal file
View file

@ -0,0 +1,24 @@
package model
// Timestamp represents a time in a subtitle file
type Timestamp struct {
Hours int
Minutes int
Seconds int
Milliseconds int
}
// Lyrics represents a lyrics file with metadata and content
type Lyrics struct {
Metadata map[string]string
Timeline []Timestamp
Content []string
}
// SRTEntry represents a single entry in an SRT file
type SRTEntry struct {
Number int
StartTime Timestamp
EndTime Timestamp
Content string
}

79
internal/sync/sync.go Normal file
View file

@ -0,0 +1,79 @@
package sync
import (
"fmt"
"path/filepath"
"strings"
"sub-cli/internal/format/lrc"
"sub-cli/internal/model"
)
// SyncLyrics synchronizes the timeline of a source lyrics file with a target lyrics file
func SyncLyrics(sourceFile, targetFile string) error {
sourceFmt := strings.TrimPrefix(filepath.Ext(sourceFile), ".")
targetFmt := strings.TrimPrefix(filepath.Ext(targetFile), ".")
// Currently only supports LRC files
if sourceFmt != "lrc" || targetFmt != "lrc" {
return fmt.Errorf("sync only supports LRC files currently")
}
source, err := lrc.Parse(sourceFile)
if err != nil {
return fmt.Errorf("error parsing source file: %w", err)
}
target, err := lrc.Parse(targetFile)
if err != nil {
return fmt.Errorf("error parsing target file: %w", err)
}
// Apply timeline from source to target
syncedLyrics := syncTimeline(source, target)
// Write the synced lyrics to the target file
return lrc.Generate(syncedLyrics, targetFile)
}
// syncTimeline applies the timeline from the source lyrics to the target lyrics
func syncTimeline(source, target model.Lyrics) model.Lyrics {
result := model.Lyrics{
Metadata: target.Metadata,
Content: target.Content,
}
// Use source timeline if available and lengths match
if len(source.Timeline) > 0 && len(source.Timeline) == len(target.Content) {
result.Timeline = source.Timeline
} else if len(source.Timeline) > 0 {
// If lengths don't match, scale timeline
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
}
return result
}
// scaleTimeline scales a timeline to match a different number of entries
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
if targetCount <= 0 || len(timeline) == 0 {
return []model.Timestamp{}
}
result := make([]model.Timestamp, targetCount)
if targetCount == 1 {
result[0] = timeline[0]
return result
}
sourceLength := len(timeline)
for i := 0; i < targetCount; i++ {
// Scale index to match source timeline
sourceIndex := i * (sourceLength - 1) / (targetCount - 1)
result[i] = timeline[sourceIndex]
}
return result
}

224
lrc.go
View file

@ -1,224 +0,0 @@
package main
import (
"bufio"
"fmt"
"os"
"regexp"
"strconv"
"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) {
file, err := os.Open(filePath)
if err != nil {
return Lyrics{}, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
lyrics := Lyrics{
Metadata: make(map[string]string),
}
timeLineRegex := regexp.MustCompile(`\[((\d+:)?\d+:\d+(\.\d+)?)\]`)
tagRegex := regexp.MustCompile(`\[(\w+):(.+)\]`)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") {
if timeLineRegex.MatchString(line) {
// Timeline
timeStr := timeLineRegex.FindString(line)
timestamp, err := parseTimestamp(timeStr)
if err != nil {
return Lyrics{}, err
}
lyrics.Timeline = append(lyrics.Timeline, timestamp)
// Content
content := timeLineRegex.ReplaceAllString(line, "")
lyrics.Content = append(lyrics.Content, strings.TrimSpace(content))
} else {
// Metadata
matches := tagRegex.FindStringSubmatch(line)
if len(matches) == 3 {
lyrics.Metadata[matches[1]] = strings.TrimSpace(matches[2])
}
}
}
}
if err := scanner.Err(); err != nil {
return Lyrics{}, err
}
return lyrics, nil
}
func saveLyrics(filePath string, lyrics Lyrics) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
// Write metadata
for key, value := range lyrics.Metadata {
fmt.Fprintf(file, "[%s: %s]\n", key, value)
}
// Write timeline and content
for i := 0; i < len(lyrics.Timeline); i++ {
fmt.Fprintf(file, "%s %s\n", timestampToString(lyrics.Timeline[i]), lyrics.Content[i])
}
return nil
}
func syncLyrics(args []string) {
if len(args) < 2 {
fmt.Println(SYNC_USAGE)
return
}
sourceFile := args[0]
targetFile := args[1]
sourceLyrics, err := parseLyrics(sourceFile)
if err != nil {
fmt.Println("Error parsing source lyrics file:", err)
return
}
targetLyrics, err := parseLyrics(targetFile)
if err != nil {
fmt.Println("Error parsing target lyrics file:", err)
return
}
minLength := len(sourceLyrics.Timeline)
if len(targetLyrics.Timeline) < minLength {
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++ {
targetLyrics.Timeline[i] = sourceLyrics.Timeline[i]
}
// save to target, name it as "<filename>_synced.lrc"
targetFileName := strings.TrimSuffix(targetFile, ".lrc") + "_synced.lrc"
err = saveLyrics(targetFileName, targetLyrics)
if err != nil {
fmt.Println("Error saving synced lyrics file:", err)
return
}
}
func convertLyrics(sourceFile, targetFile, targetFmt string) {
switch targetFmt {
case "txt":
lrcToTxt(sourceFile, targetFile)
case "srt":
lrcToSrt(sourceFile, targetFile)
default:
fmt.Printf("unsupported target format: %s\n", targetFmt)
}
}
func fmtLyrics(args []string) {
if len(args) < 1 {
fmt.Println("Usage: lyc-cli fmt <source>")
return
}
sourceFile := args[0]
sourceLyrics, err := parseLyrics(sourceFile)
if err != nil {
fmt.Println("Error parsing source lyrics file:", err)
return
}
// save to target (source_name_fmt.lrc)
targetFile := strings.TrimSuffix(sourceFile, ".lrc") + "_fmt.lrc"
err = saveLyrics(targetFile, sourceLyrics)
if err != nil {
fmt.Println("Error saving formatted lyrics file:", err)
return
}
}
func timestampToString(ts Timestamp) string {
if ts.Hours > 0 {
return fmt.Sprintf("[%02d:%02d:%02d.%03d]", ts.Hours, ts.Minutes, ts.Seconds, ts.Milliseconds)
}
return fmt.Sprintf("[%02d:%02d.%03d]", ts.Minutes, ts.Seconds, ts.Milliseconds)
}

24
main.go
View file

@ -1,29 +1,9 @@
package main
import (
"fmt"
"os"
"sub-cli/cmd"
)
func main() {
// parse args
if len(os.Args) < 2 {
fmt.Println(USAGE)
return
}
switch os.Args[1] {
case "sync":
syncLyrics(os.Args[2:])
case "convert":
convert(os.Args[2:])
case "fmt":
fmtLyrics(os.Args[2:])
case "version":
fmt.Printf("sub-cli version %s\n", VERSION)
case "help":
fmt.Println(USAGE)
default:
fmt.Println("Unknown command")
fmt.Println(USAGE)
}
cmd.Execute()
}

View file

@ -1,39 +0,0 @@
package main
type Timestamp struct {
Hours int
Minutes int
Seconds int
Milliseconds int
}
type Lyrics struct {
Metadata map[string]string
Timeline []Timestamp
Content []string
}
type SRTEntry struct {
Number int
StartTime Timestamp
EndTime Timestamp
Content string
}
const (
VERSION = "0.3.0"
USAGE = `Usage: sub-cli [command] [options]
Commands:
sync Synchronize timeline of two lyrics files
convert Convert lyrics file to another format
fmt Format lyrics file
help Show help`
SYNC_USAGE = `Usage: sub-cli sync <source> <target>`
CONVERT_USAGE = `Usage: sub-cli convert <source> <target>
Note:
Target format is determined by file extension. Supported formats:
.txt Plain text format(No meta/timeline tags, only support as target format)
.srt SubRip Subtitle format
.lrc LRC format`
)

120
srt.go
View file

@ -1,120 +0,0 @@
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
var isContent bool
var contentBuffer strings.Builder
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
if currentEntry.Number != 0 {
currentEntry.Content = contentBuffer.String()
entries = append(entries, currentEntry)
currentEntry = SRTEntry{}
isContent = false
contentBuffer.Reset()
}
continue
}
if currentEntry.Number == 0 {
currentEntry.Number, _ = strconv.Atoi(line)
} else if isEntryTimeStampUnset(currentEntry) {
times := strings.Split(line, " --> ")
if len(times) == 2 {
currentEntry.StartTime = parseSRTTimestamp(times[0])
currentEntry.EndTime = parseSRTTimestamp(times[1])
isContent = true
}
} else if isContent {
if contentBuffer.Len() > 0 {
contentBuffer.WriteString("\n")
}
contentBuffer.WriteString(line)
}
}
if currentEntry.Number != 0 {
currentEntry.Content = contentBuffer.String()
entries = append(entries, currentEntry)
}
return entries, scanner.Err()
}
func isEntryTimeStampUnset(currentEntry SRTEntry) bool {
return currentEntry.StartTime.Hours == 0 && currentEntry.StartTime.Minutes == 0 &&
currentEntry.StartTime.Seconds == 0 && currentEntry.StartTime.Milliseconds == 0 &&
currentEntry.EndTime.Hours == 0 && currentEntry.EndTime.Minutes == 0 &&
currentEntry.EndTime.Seconds == 0 && currentEntry.EndTime.Milliseconds == 0
}
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 {
parts := strings.Split(timeStr, ",")
if len(parts) != 2 {
return Timestamp{}
}
timeParts := strings.Split(parts[0], ":")
if len(timeParts) != 3 {
return Timestamp{}
}
hours, _ := strconv.Atoi(timeParts[0])
minutes, _ := strconv.Atoi(timeParts[1])
seconds, _ := strconv.Atoi(timeParts[2])
milliseconds, _ := strconv.Atoi(parts[1])
return Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// 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,
}
}