sub-cli/internal/format/lrc/lrc.go
2025-04-23 10:44:08 +08:00

266 lines
6.3 KiB
Go

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)
}
// ConvertToSubtitle converts LRC file to our intermediate Subtitle representation
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
lyrics, err := Parse(filePath)
if err != nil {
return model.Subtitle{}, err
}
subtitle := model.NewSubtitle()
subtitle.Format = "lrc"
// Copy metadata
for key, value := range lyrics.Metadata {
subtitle.Metadata[key] = value
}
// Check for specific LRC metadata we should use for title
if title, ok := lyrics.Metadata["ti"]; ok {
subtitle.Title = title
}
// Create entries from timeline and content
for i, content := range lyrics.Content {
if i >= len(lyrics.Timeline) {
break
}
entry := model.NewSubtitleEntry()
entry.Index = i + 1
entry.StartTime = lyrics.Timeline[i]
// Set end time based on next timeline entry if available, otherwise add a few seconds
if i+1 < len(lyrics.Timeline) {
entry.EndTime = lyrics.Timeline[i+1]
} else {
// Default end time: start time + 3 seconds
entry.EndTime = model.Timestamp{
Hours: entry.StartTime.Hours,
Minutes: entry.StartTime.Minutes,
Seconds: entry.StartTime.Seconds + 3,
Milliseconds: entry.StartTime.Milliseconds,
}
// Handle overflow
if entry.EndTime.Seconds >= 60 {
entry.EndTime.Seconds -= 60
entry.EndTime.Minutes++
}
if entry.EndTime.Minutes >= 60 {
entry.EndTime.Minutes -= 60
entry.EndTime.Hours++
}
}
entry.Text = content
subtitle.Entries = append(subtitle.Entries, entry)
}
return subtitle, nil
}
// ConvertFromSubtitle converts our intermediate Subtitle representation to LRC format
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
lyrics := model.Lyrics{
Metadata: make(map[string]string),
}
// Copy metadata
for key, value := range subtitle.Metadata {
lyrics.Metadata[key] = value
}
// Add title if present and not already in metadata
if subtitle.Title != "" && lyrics.Metadata["ti"] == "" {
lyrics.Metadata["ti"] = subtitle.Title
}
// Convert entries to timeline and content
for _, entry := range subtitle.Entries {
lyrics.Timeline = append(lyrics.Timeline, entry.StartTime)
lyrics.Content = append(lyrics.Content, entry.Text)
}
return Generate(lyrics, filePath)
}