v7.1.8
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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: "*.*",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user