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