From 9b0e2ed6dc5710fd8d40a5022d28653abbda3aa5 Mon Sep 17 00:00:00 2001 From: cdn0x12 Date: Wed, 23 Apr 2025 08:01:13 +0800 Subject: [PATCH] refactor --- .gitignore | 6 +- README.md | 9 +- cmd/root.go | 80 ++++++++++++ convert.go | 128 ------------------ internal/config/constants.go | 23 ++++ internal/converter/converter.go | 137 +++++++++++++++++++ internal/format/lrc/lrc.go | 182 ++++++++++++++++++++++++++ internal/format/srt/srt.go | 137 +++++++++++++++++++ internal/formatter/formatter.go | 21 +++ internal/model/model.go | 24 ++++ internal/sync/sync.go | 79 +++++++++++ lrc.go | 224 -------------------------------- main.go | 24 +--- model.go | 39 ------ srt.go | 120 ----------------- 15 files changed, 693 insertions(+), 540 deletions(-) create mode 100644 cmd/root.go delete mode 100644 convert.go create mode 100644 internal/config/constants.go create mode 100644 internal/converter/converter.go create mode 100644 internal/format/lrc/lrc.go create mode 100644 internal/format/srt/srt.go create mode 100644 internal/formatter/formatter.go create mode 100644 internal/model/model.go create mode 100644 internal/sync/sync.go delete mode 100644 lrc.go delete mode 100644 model.go delete mode 100644 srt.go diff --git a/.gitignore b/.gitignore index f491c7d..6eec6dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -release/ -release.ps1 +sub-cli +sub-cli.exe + +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 6661292..e1aff43 100644 --- a/README.md +++ b/README.md @@ -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 - diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..efec2fc --- /dev/null +++ b/cmd/root.go @@ -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 ") + return + } + + filePath := args[0] + + if err := formatter.Format(filePath); err != nil { + fmt.Printf("Error: %v\n", err) + } +} diff --git a/convert.go b/convert.go deleted file mode 100644 index 510eace..0000000 --- a/convert.go +++ /dev/null @@ -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) - } -} diff --git a/internal/config/constants.go b/internal/config/constants.go new file mode 100644 index 0000000..cde53c4 --- /dev/null +++ b/internal/config/constants.go @@ -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 ` + +// ConvertUsage stores the usage information for the convert command +const ConvertUsage = `Usage: sub-cli convert + 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` diff --git a/internal/converter/converter.go b/internal/converter/converter.go new file mode 100644 index 0000000..d002501 --- /dev/null +++ b/internal/converter/converter.go @@ -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 +} diff --git a/internal/format/lrc/lrc.go b/internal/format/lrc/lrc.go new file mode 100644 index 0000000..6ad4d9f --- /dev/null +++ b/internal/format/lrc/lrc.go @@ -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) +} diff --git a/internal/format/srt/srt.go b/internal/format/srt/srt.go new file mode 100644 index 0000000..0934126 --- /dev/null +++ b/internal/format/srt/srt.go @@ -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 +} diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go new file mode 100644 index 0000000..ddee025 --- /dev/null +++ b/internal/formatter/formatter.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..520aa7b --- /dev/null +++ b/internal/model/model.go @@ -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 +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 0000000..1df9b4f --- /dev/null +++ b/internal/sync/sync.go @@ -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 +} diff --git a/lrc.go b/lrc.go deleted file mode 100644 index 955eaec..0000000 --- a/lrc.go +++ /dev/null @@ -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 "_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 ") - 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) -} diff --git a/main.go b/main.go index 9389699..a6d8239 100644 --- a/main.go +++ b/main.go @@ -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() } diff --git a/model.go b/model.go deleted file mode 100644 index 88ce896..0000000 --- a/model.go +++ /dev/null @@ -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 ` - CONVERT_USAGE = `Usage: sub-cli convert - 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` -) diff --git a/srt.go b/srt.go deleted file mode 100644 index 58cd75f..0000000 --- a/srt.go +++ /dev/null @@ -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, - } -}