refactor
This commit is contained in:
parent
aedc4a4518
commit
9b0e2ed6dc
15 changed files with 693 additions and 540 deletions
23
internal/config/constants.go
Normal file
23
internal/config/constants.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package config
|
||||
|
||||
// Version stores the current application version
|
||||
const Version = "0.3.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`
|
137
internal/converter/converter.go
Normal file
137
internal/converter/converter.go
Normal 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
182
internal/format/lrc/lrc.go
Normal 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
137
internal/format/srt/srt.go
Normal 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
|
||||
}
|
21
internal/formatter/formatter.go
Normal file
21
internal/formatter/formatter.go
Normal 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
24
internal/model/model.go
Normal 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
79
internal/sync/sync.go
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue