278 lines
6.2 KiB
Go
278 lines
6.2 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
id3v2 "github.com/bogem/id3v2/v2"
|
|
"github.com/go-flac/flacvorbis"
|
|
"github.com/go-flac/go-flac"
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
type EmbeddedLyrics struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Lyrics string `json:"lyrics"`
|
|
Source string `json:"source"`
|
|
Synced bool `json:"synced"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
var lrcTimestampRe = regexp.MustCompile(`\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]`)
|
|
|
|
func isSyncedLyrics(lyrics string) bool {
|
|
return lrcTimestampRe.MatchString(lyrics)
|
|
}
|
|
|
|
func ReadEmbeddedLyrics(filePath string) (*EmbeddedLyrics, error) {
|
|
if !fileExists(filePath) {
|
|
return nil, fmt.Errorf("file does not exist")
|
|
}
|
|
|
|
result := &EmbeddedLyrics{
|
|
Path: filePath,
|
|
Name: filepath.Base(filePath),
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
var lyrics string
|
|
var err error
|
|
|
|
switch ext {
|
|
case ".lrc", ".txt":
|
|
var content []byte
|
|
content, err = os.ReadFile(filePath)
|
|
if err == nil {
|
|
lyrics = string(content)
|
|
result.Source = "lrc"
|
|
}
|
|
case ".flac":
|
|
lyrics, err = readFlacLyrics(filePath)
|
|
result.Source = "embedded"
|
|
case ".mp3":
|
|
lyrics, err = readMp3Lyrics(filePath)
|
|
result.Source = "embedded"
|
|
case ".m4a", ".aac", ".opus", ".ogg":
|
|
lyrics, err = readLyricsWithFFprobe(filePath)
|
|
result.Source = "embedded"
|
|
default:
|
|
return nil, fmt.Errorf("unsupported file format: %s", ext)
|
|
}
|
|
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
|
|
lyrics = strings.TrimSpace(lyrics)
|
|
if lyrics == "" {
|
|
result.Error = "No lyrics found in this file"
|
|
return result, nil
|
|
}
|
|
|
|
result.Lyrics = lyrics
|
|
result.Synced = isSyncedLyrics(lyrics)
|
|
return result, nil
|
|
}
|
|
|
|
func readFlacLyrics(filePath string) (string, error) {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
for _, block := range f.Meta {
|
|
if block.Type != flac.VorbisComment {
|
|
continue
|
|
}
|
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, comment := range cmt.Comments {
|
|
parts := strings.SplitN(comment, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
fieldName := strings.ToUpper(parts[0])
|
|
switch fieldName {
|
|
case "LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS", "LYRICS-XXX":
|
|
if strings.TrimSpace(parts[1]) != "" {
|
|
return parts[1], nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func readMp3Lyrics(filePath string) (string, error) {
|
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open MP3 file: %w", err)
|
|
}
|
|
defer tag.Close()
|
|
|
|
frames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
|
for _, frame := range frames {
|
|
uslf, ok := frame.(id3v2.UnsynchronisedLyricsFrame)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(uslf.Lyrics) != "" {
|
|
return uslf.Lyrics, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func readLyricsWithFFprobe(filePath string) (string, error) {
|
|
ffprobePath, err := GetFFprobePath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
|
return "", fmt.Errorf("invalid ffprobe executable: %w", err)
|
|
}
|
|
|
|
cmd := exec.Command(ffprobePath,
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
filePath,
|
|
)
|
|
|
|
setHideWindow(cmd)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var probe struct {
|
|
Format struct {
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"format"`
|
|
Streams []struct {
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"streams"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &probe); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
collect := func(tags map[string]string) string {
|
|
for key, value := range tags {
|
|
lk := strings.ToLower(key)
|
|
if lk == "lyrics" || strings.HasPrefix(lk, "lyrics-") || lk == "unsyncedlyrics" {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if lyrics := collect(probe.Format.Tags); lyrics != "" {
|
|
return lyrics, nil
|
|
}
|
|
for _, stream := range probe.Streams {
|
|
if lyrics := collect(stream.Tags); lyrics != "" {
|
|
return lyrics, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
type ExtractLyricsResult struct {
|
|
Path string `json:"path"`
|
|
OutputPath string `json:"output_path,omitempty"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
|
}
|
|
|
|
func ExtractLyricsToLRC(filePath string, overwrite bool) (*ExtractLyricsResult, error) {
|
|
result := &ExtractLyricsResult{Path: filePath}
|
|
|
|
embedded, err := ReadEmbeddedLyrics(filePath)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return result, nil
|
|
}
|
|
|
|
if embedded.Error != "" {
|
|
result.Error = embedded.Error
|
|
return result, nil
|
|
}
|
|
|
|
if strings.TrimSpace(embedded.Lyrics) == "" {
|
|
result.Error = "No lyrics found in this file"
|
|
return result, nil
|
|
}
|
|
|
|
dir := filepath.Dir(filePath)
|
|
base := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
|
outputPath := filepath.Join(dir, base+".lrc")
|
|
result.OutputPath = outputPath
|
|
|
|
if !overwrite {
|
|
if info, statErr := os.Stat(outputPath); statErr == nil && info.Size() > 0 {
|
|
result.AlreadyExists = true
|
|
result.Error = "LRC file already exists"
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
content := embedded.Lyrics
|
|
if !strings.HasSuffix(content, "\n") {
|
|
content += "\n"
|
|
}
|
|
|
|
if writeErr := os.WriteFile(outputPath, []byte(content), 0644); writeErr != nil {
|
|
result.Error = fmt.Sprintf("failed to write LRC file: %v", writeErr)
|
|
return result, nil
|
|
}
|
|
|
|
result.Success = true
|
|
return result, nil
|
|
}
|
|
|
|
func SelectLyricsFiles(ctx context.Context) ([]string, error) {
|
|
return runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
|
|
Title: "Select Lyrics or Audio Files",
|
|
Filters: []runtime.FileFilter{
|
|
{
|
|
DisplayName: "Lyrics & Audio (*.lrc, *.flac, *.mp3, *.m4a, *.opus)",
|
|
Pattern: "*.lrc;*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg;*.txt",
|
|
},
|
|
{
|
|
DisplayName: "LRC Files (*.lrc)",
|
|
Pattern: "*.lrc",
|
|
},
|
|
{
|
|
DisplayName: "Audio Files (*.flac, *.mp3, *.m4a, *.opus)",
|
|
Pattern: "*.flac;*.mp3;*.m4a;*.aac;*.opus;*.ogg",
|
|
},
|
|
{
|
|
DisplayName: "All Files (*.*)",
|
|
Pattern: "*.*",
|
|
},
|
|
},
|
|
})
|
|
}
|