Compare commits
No commits in common. "3af537c3ddb01b9bee634412cbfa5cfd842940d1" and "aedc4a4518448311538476a1fd25f7951605cc26" have entirely different histories.
3af537c3dd
...
aedc4a4518
15 changed files with 540 additions and 693 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,4 +1,2 @@
|
||||||
sub-cli
|
release/
|
||||||
sub-cli.exe
|
release.ps1
|
||||||
|
|
||||||
.DS_Store
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
# sub-cli
|
# lrc-cli
|
||||||
|
|
||||||
A CLI tool for subtitle.
|
A CLI tool for LRC.
|
||||||
See [releases](https://git.owu.one/starset-mirror/sub-cli/releases) for binaries.
|
See [releases](https://git.owu.one/starset-mirror/lrc-cli/releases) for binaries.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
./sub-cli --help
|
./lrc-cli --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
AGPL-3.0
|
AGPL-3.0
|
||||||
|
|
||||||
|
|
80
cmd/root.go
80
cmd/root.go
|
@ -1,80 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
128
convert.go
Normal file
128
convert.go
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +0,0 @@
|
||||||
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`
|
|
|
@ -1,137 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,182 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,137 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,79 +0,0 @@
|
||||||
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
Normal file
224
lrc.go
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
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
24
main.go
|
@ -1,9 +1,29 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub-cli/cmd"
|
"fmt"
|
||||||
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmd.Execute()
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
39
model.go
Normal file
39
model.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
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
Normal file
120
srt.go
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue