Compare commits

...

10 commits

Author SHA1 Message Date
CDN
9f96b255ae
feat: ci
All checks were successful
Build and Release / Build (push) Successful in 23s
Build and Release / Create Release (push) Has been skipped
2025-04-23 07:45:54 +08:00
CDN
28b49b7f78
chore: rename 2025-04-23 07:45:43 +08:00
CDN18
1556b11d5f
fix: srt to lrc 2024-10-10 15:22:06 +08:00
CDN18
82e67b1a32
chore: not WIP anymore 2024-09-27 21:04:33 +08:00
CDN18
9d031072ad
chore: bump version 2024-09-27 21:00:46 +08:00
CDN18
7d5a8bdf54
feat: support srt 2024-09-27 20:59:59 +08:00
CDN18
00deeaf425
chore: prepare for srt support + force sync lrc timestamp if timeline length mismatch 2024-09-27 20:39:48 +08:00
CDN18
6875b43b78
refactor: parse Timeline as Timestamp[] 2024-09-27 20:28:22 +08:00
CDN18
45e42ce4ff
feat: version 2024-08-11 12:20:05 +08:00
CDN18
1b1862cb3f
feat: convert to plain text 2024-08-11 12:13:11 +08:00
9 changed files with 579 additions and 139 deletions

68
.forgejo/workflows/ci.yml Normal file
View file

@ -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 }}

View file

@ -1,6 +1,6 @@
# lrc-cli # 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. See [releases](https://git.owu.one/starset-mirror/lrc-cli/releases) for binaries.
## Usage ## Usage

128
convert.go Normal file
View 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)
}
}

2
go.mod
View file

@ -1,3 +1,3 @@
module lrc-cli module sub-cli
go 1.22.6 go 1.22.6

224
lrc.go Normal file
View 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)
}

View file

@ -14,6 +14,12 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "sync": case "sync":
syncLyrics(os.Args[2:]) 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": case "help":
fmt.Println(USAGE) fmt.Println(USAGE)
default: default:

View file

@ -1,14 +1,39 @@
package main package main
type Timestamp struct {
Hours int
Minutes int
Seconds int
Milliseconds int
}
type Lyrics struct { type Lyrics struct {
Metadata map[string]string Metadata map[string]string
Timeline []string Timeline []Timestamp
Content []string Content []string
} }
const USAGE = `Usage: lyc-cli [command] [options] type SRTEntry struct {
Number int
StartTime Timestamp
EndTime Timestamp
Content string
}
const (
VERSION = "0.3.0"
USAGE = `Usage: sub-cli [command] [options]
Commands: Commands:
sync Synchronize timeline of two lyrics files sync Synchronize timeline of two lyrics files
convert Convert lyrics file to another format
fmt Format lyrics file
help Show help` help Show help`
const SYNC_USAGE = `Usage: lyc-cli sync <source> <target>` 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
View 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,
}
}

131
util.go
View file

@ -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 "<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 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
}