diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..d8d78c1 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,68 @@ +name: Build and Release + +on: + push: + branches: [ main ] + tags: + - 'v*' + pull_request: + branches: [ main ] + +jobs: + build: + name: Build + runs-on: docker + strategy: + matrix: + goos: [linux, windows, darwin] + goarch: [amd64, arm64] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + run: | + export GOOS=${{ matrix.goos }} + export GOARCH=${{ matrix.goarch }} + export CGO_ENABLED=0 + go build -v -o sub-cli-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} + + - name: Upload build artifact + uses: forgejo/upload-artifact@v4 + with: + name: sub-cli-${{ matrix.goos }}-${{ matrix.goarch }} + path: sub-cli-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} + retention-days: 7 + + release: + name: Create Release + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: forgejo/download-artifact@v4 + + - name: Display structure of downloaded files + run: ls -R + + - name: Create release + id: create_release + uses: https://github.com/softprops/action-gh-release@v2 + with: + name: Release ${{ github.ref_name }} + draft: false + prerelease: false + files: | + sub-cli-linux-amd64/sub-cli-linux-amd64 + sub-cli-linux-arm64/sub-cli-linux-arm64 + sub-cli-darwin-amd64/sub-cli-darwin-amd64 + sub-cli-darwin-arm64/sub-cli-darwin-arm64 + sub-cli-windows-amd64/sub-cli-windows-amd64.exe + env: + GITHUB_TOKEN: ${{ secrets.ACTIONS_TOKEN }} diff --git a/README.md b/README.md index 00f775d..6661292 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # lrc-cli -[WIP] A CLI tool for LRC. +A CLI tool for LRC. See [releases](https://git.owu.one/starset-mirror/lrc-cli/releases) for binaries. ## Usage diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..510eace --- /dev/null +++ b/convert.go @@ -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) + } +} diff --git a/go.mod b/go.mod index e7dab73..5e96722 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module lrc-cli +module sub-cli go 1.22.6 diff --git a/lrc.go b/lrc.go new file mode 100644 index 0000000..955eaec --- /dev/null +++ b/lrc.go @@ -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 "_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 f4b22ba..9389699 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,12 @@ func main() { 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: diff --git a/model.go b/model.go index ae4a6f2..88ce896 100644 --- a/model.go +++ b/model.go @@ -1,14 +1,39 @@ package main +type Timestamp struct { + Hours int + Minutes int + Seconds int + Milliseconds int +} + type Lyrics struct { Metadata map[string]string - Timeline []string + Timeline []Timestamp Content []string } -const USAGE = `Usage: lyc-cli [command] [options] -Commands: - sync Synchronize timeline of two lyrics files - help Show help` +type SRTEntry struct { + Number int + StartTime Timestamp + EndTime Timestamp + Content string +} -const SYNC_USAGE = `Usage: lyc-cli sync ` +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 new file mode 100644 index 0000000..58cd75f --- /dev/null +++ b/srt.go @@ -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, + } +} diff --git a/util.go b/util.go deleted file mode 100644 index 88aa0bf..0000000 --- a/util.go +++ /dev/null @@ -1,131 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "regexp" - "strings" -) - -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 Tag - time := timeLineRegex.FindString(line) - lyrics.Timeline = append(lyrics.Timeline, time) - // 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 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 - } - - // Sync timeline - if len(sourceLyrics.Timeline) != len(targetLyrics.Timeline) { - fmt.Println("Warning: Timeline length mismatch") - return - } - minLength := len(sourceLyrics.Timeline) - if len(targetLyrics.Timeline) < minLength { - minLength = len(targetLyrics.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 printLyricsInfo(lyrics Lyrics) { -// fmt.Println("Metadata:") -// for key, value := range lyrics.Metadata { -// fmt.Printf("%s: %s\n", key, value) -// } - -// fmt.Println("\nTimeline:") -// for _, time := range lyrics.Timeline { -// fmt.Println(time) -// } - -// fmt.Println("\nLyrics Content:") -// for _, content := range lyrics.Content { -// fmt.Println(content) -// } -// } - -func 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", lyrics.Timeline[i], lyrics.Content[i]) - } - - return nil -}