266 lines
6.3 KiB
Go
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)
|
|
}
|