1095 lines
26 KiB
Go
1095 lines
26 KiB
Go
package backend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
pathfilepath "path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
id3v2 "github.com/bogem/id3v2/v2"
|
|
"github.com/go-flac/flacpicture"
|
|
"github.com/go-flac/flacvorbis"
|
|
"github.com/go-flac/go-flac"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
type Metadata struct {
|
|
Title string
|
|
Artist string
|
|
Album string
|
|
AlbumArtist string
|
|
Date string
|
|
ReleaseDate string
|
|
TrackNumber int
|
|
TotalTracks int
|
|
DiscNumber int
|
|
TotalDiscs int
|
|
URL string
|
|
Copyright string
|
|
Publisher string
|
|
Lyrics string
|
|
Description string
|
|
ISRC string
|
|
Genre string
|
|
}
|
|
|
|
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
|
f, err := flac.ParseFile(filepath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
var cmtIdx = -1
|
|
for idx, block := range f.Meta {
|
|
if block.Type == flac.VorbisComment {
|
|
cmtIdx = idx
|
|
break
|
|
}
|
|
}
|
|
|
|
cmt := flacvorbis.New()
|
|
|
|
if metadata.Title != "" {
|
|
_ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title)
|
|
}
|
|
if metadata.Artist != "" {
|
|
_ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist)
|
|
}
|
|
if metadata.Album != "" {
|
|
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
|
|
}
|
|
if metadata.AlbumArtist != "" {
|
|
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
|
|
}
|
|
if metadata.Date != "" {
|
|
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
|
}
|
|
if metadata.TrackNumber > 0 {
|
|
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
|
}
|
|
if metadata.TotalTracks > 0 {
|
|
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
|
|
}
|
|
if metadata.DiscNumber > 0 {
|
|
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
|
}
|
|
if metadata.TotalDiscs > 0 {
|
|
_ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs))
|
|
}
|
|
if metadata.Copyright != "" {
|
|
_ = cmt.Add("COPYRIGHT", metadata.Copyright)
|
|
}
|
|
if metadata.Publisher != "" {
|
|
_ = cmt.Add("PUBLISHER", metadata.Publisher)
|
|
}
|
|
if metadata.Description != "" {
|
|
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
|
}
|
|
|
|
if metadata.ISRC != "" {
|
|
_ = cmt.Add("ISRC", metadata.ISRC)
|
|
}
|
|
|
|
if metadata.Genre != "" {
|
|
_ = cmt.Add("GENRE", metadata.Genre)
|
|
}
|
|
|
|
if metadata.Lyrics != "" {
|
|
_ = cmt.Add("LYRICS", metadata.Lyrics)
|
|
}
|
|
|
|
cmtBlock := cmt.Marshal()
|
|
if cmtIdx < 0 {
|
|
f.Meta = append(f.Meta, &cmtBlock)
|
|
} else {
|
|
f.Meta[cmtIdx] = &cmtBlock
|
|
}
|
|
|
|
if coverPath != "" && fileExists(coverPath) {
|
|
if err := embedCoverArt(f, coverPath); err != nil {
|
|
fmt.Printf("Warning: Failed to embed cover art: %v\n", err)
|
|
}
|
|
}
|
|
|
|
if err := f.Save(filepath); err != nil {
|
|
return fmt.Errorf("failed to save FLAC file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func embedCoverArt(f *flac.File, coverPath string) error {
|
|
imgData, err := os.ReadFile(coverPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read cover image: %w", err)
|
|
}
|
|
|
|
picture, err := flacpicture.NewFromImageData(
|
|
flacpicture.PictureTypeFrontCover,
|
|
"Cover",
|
|
imgData,
|
|
"image/jpeg",
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create picture block: %w", err)
|
|
}
|
|
|
|
pictureBlock := picture.Marshal()
|
|
|
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
|
if f.Meta[i].Type == flac.Picture {
|
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
|
}
|
|
}
|
|
|
|
f.Meta = append(f.Meta, &pictureBlock)
|
|
|
|
return nil
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
func extractYear(releaseDate string) string {
|
|
if releaseDate == "" {
|
|
return ""
|
|
}
|
|
|
|
if len(releaseDate) >= 4 {
|
|
return releaseDate[:4]
|
|
}
|
|
return releaseDate
|
|
}
|
|
|
|
func EmbedLyricsOnly(filepath string, lyrics string) error {
|
|
if lyrics == "" {
|
|
return nil
|
|
}
|
|
f, err := flac.ParseFile(filepath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
var cmtIdx = -1
|
|
var existingCmt *flacvorbis.MetaDataBlockVorbisComment
|
|
for idx, block := range f.Meta {
|
|
if block.Type == flac.VorbisComment {
|
|
cmtIdx = idx
|
|
existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block)
|
|
if err != nil {
|
|
existingCmt = nil
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
cmt := flacvorbis.New()
|
|
|
|
if existingCmt != nil {
|
|
for _, comment := range existingCmt.Comments {
|
|
parts := strings.SplitN(comment, "=", 2)
|
|
if len(parts) == 2 {
|
|
fieldName := strings.ToUpper(parts[0])
|
|
if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" {
|
|
_ = cmt.Add(parts[0], parts[1])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = cmt.Add("LYRICS", lyrics)
|
|
|
|
cmtBlock := cmt.Marshal()
|
|
if cmtIdx < 0 {
|
|
f.Meta = append(f.Meta, &cmtBlock)
|
|
} else {
|
|
f.Meta[cmtIdx] = &cmtBlock
|
|
}
|
|
|
|
if err := f.Save(filepath); err != nil {
|
|
return fmt.Errorf("failed to save FLAC file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ExtractCoverArt(filePath string) (string, error) {
|
|
filePath = norm.NFC.String(filePath)
|
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
|
|
|
var coverPath string
|
|
var err error
|
|
|
|
switch ext {
|
|
case ".mp3":
|
|
coverPath, err = extractCoverFromMp3(filePath)
|
|
case ".m4a", ".flac":
|
|
coverPath, err = extractCoverFromM4AOrFlac(filePath)
|
|
default:
|
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
|
}
|
|
|
|
if err != nil || coverPath == "" {
|
|
fmt.Printf("[ExtractCoverArt] Library extraction failed for %s, trying FFmpeg fallback...\n", filePath)
|
|
ffmpegCover, ffmpegErr := extractCoverWithFFmpeg(filePath)
|
|
if ffmpegErr == nil {
|
|
return ffmpegCover, nil
|
|
}
|
|
return coverPath, err
|
|
}
|
|
|
|
return coverPath, nil
|
|
}
|
|
|
|
func extractCoverWithFFmpeg(filePath string) (string, error) {
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
tmpFile.Close()
|
|
|
|
cmd := exec.Command(ffmpegPath,
|
|
"-i", filePath,
|
|
"-an",
|
|
"-vframes", "1",
|
|
"-f", "image2",
|
|
"-update", "1",
|
|
"-y",
|
|
tmpPath,
|
|
)
|
|
|
|
setHideWindow(cmd)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return "", fmt.Errorf("ffmpeg cover extraction failed: %v, output: %s", err, string(output))
|
|
}
|
|
|
|
if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
|
|
os.Remove(tmpPath)
|
|
return "", fmt.Errorf("ffmpeg produced empty cover file")
|
|
}
|
|
|
|
return tmpPath, nil
|
|
}
|
|
|
|
func extractCoverFromMp3(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()
|
|
|
|
pictures := tag.GetFrames(tag.CommonID("Attached picture"))
|
|
if len(pictures) == 0 {
|
|
return "", fmt.Errorf("no cover art found")
|
|
}
|
|
|
|
pic, ok := pictures[0].(id3v2.PictureFrame)
|
|
if !ok {
|
|
return "", fmt.Errorf("invalid picture frame")
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
if _, err := tmpFile.Write(pic.Picture); err != nil {
|
|
os.Remove(tmpFile.Name())
|
|
return "", fmt.Errorf("failed to write cover art: %w", err)
|
|
}
|
|
|
|
return tmpFile.Name(), nil
|
|
}
|
|
|
|
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
|
|
|
if ext == ".flac" {
|
|
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.Picture {
|
|
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
if _, err := tmpFile.Write(pic.ImageData); err != nil {
|
|
os.Remove(tmpFile.Name())
|
|
return "", fmt.Errorf("failed to write cover art: %w", err)
|
|
}
|
|
|
|
return tmpFile.Name(), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no cover art found")
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func ExtractLyrics(filePath string) (string, error) {
|
|
filePath = norm.NFC.String(filePath)
|
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
|
|
|
var lyrics string
|
|
var err error
|
|
|
|
switch ext {
|
|
case ".mp3":
|
|
lyrics, err = extractLyricsFromMp3(filePath)
|
|
case ".flac":
|
|
lyrics, err = extractLyricsFromFlac(filePath)
|
|
case ".m4a":
|
|
return "", nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
|
}
|
|
|
|
if (err != nil || lyrics == "") && ext != ".m4a" {
|
|
fmt.Printf("[ExtractLyrics] Library extraction failed for %s, trying ffprobe fallback...\n", filePath)
|
|
ffprobeLyrics, ffprobeErr := extractLyricsWithFFprobe(filePath)
|
|
if ffprobeErr == nil && ffprobeLyrics != "" {
|
|
return ffprobeLyrics, nil
|
|
}
|
|
}
|
|
|
|
return lyrics, err
|
|
}
|
|
|
|
func extractLyricsWithFFprobe(filePath string) (string, error) {
|
|
ffprobePath, err := GetFFprobePath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
cmd := exec.Command(ffprobePath,
|
|
"-v", "quiet",
|
|
"-show_entries", "format_tags=lyrics:format_tags=unsyncedlyrics:format_tags=lyric",
|
|
"-of", "json",
|
|
filePath,
|
|
)
|
|
|
|
setHideWindow(cmd)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var result struct {
|
|
Format struct {
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"format"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tags := result.Format.Tags
|
|
for _, key := range []string{"lyrics", "unsyncedlyrics", "lyric", "LYRICS", "UNSYNCEDLYRICS", "LYRIC"} {
|
|
if val, ok := tags[key]; ok && val != "" {
|
|
return val, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func extractLyricsFromMp3(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()
|
|
|
|
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
|
if len(usltFrames) == 0 {
|
|
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
|
|
return "", nil
|
|
}
|
|
|
|
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
|
|
if !ok {
|
|
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
|
|
return "", nil
|
|
}
|
|
|
|
if uslt.Lyrics == "" {
|
|
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
|
|
return "", nil
|
|
}
|
|
|
|
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
|
|
return uslt.Lyrics, nil
|
|
}
|
|
|
|
func extractLyricsFromFlac(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 {
|
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, comment := range cmt.Comments {
|
|
parts := strings.SplitN(comment, "=", 2)
|
|
if len(parts) == 2 {
|
|
fieldName := strings.ToUpper(parts[0])
|
|
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
|
|
lyrics := parts[1]
|
|
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
|
|
return lyrics, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
|
|
return "", nil
|
|
}
|
|
|
|
func EmbedCoverArtOnly(filePath string, coverPath string) error {
|
|
if coverPath == "" || !fileExists(coverPath) {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
|
|
|
switch ext {
|
|
case ".mp3":
|
|
return embedCoverToMp3(filePath, coverPath)
|
|
case ".m4a":
|
|
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported file format: %s", ext)
|
|
}
|
|
}
|
|
|
|
func embedCoverToMp3(filePath string, coverPath 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()
|
|
|
|
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
|
|
|
artwork, err := os.ReadFile(coverPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read cover art: %w", err)
|
|
}
|
|
|
|
pic := id3v2.PictureFrame{
|
|
Encoding: id3v2.EncodingUTF8,
|
|
MimeType: "image/jpeg",
|
|
PictureType: id3v2.PTFrontCover,
|
|
Description: "Front cover",
|
|
Picture: artwork,
|
|
}
|
|
tag.AddAttachedPicture(pic)
|
|
|
|
if err := tag.Save(); err != nil {
|
|
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
|
|
if lyrics == "" {
|
|
return nil
|
|
}
|
|
|
|
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
|
if err != nil {
|
|
fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
|
validatedLyrics = lyrics
|
|
}
|
|
lyrics = validatedLyrics
|
|
|
|
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()
|
|
|
|
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
|
|
|
usltFrame := id3v2.UnsynchronisedLyricsFrame{
|
|
Encoding: id3v2.EncodingUTF8,
|
|
Language: "eng",
|
|
ContentDescriptor: "",
|
|
Lyrics: lyrics,
|
|
}
|
|
tag.AddUnsynchronisedLyricsFrame(usltFrame)
|
|
|
|
if err := tag.Save(); err != nil {
|
|
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func embedLyricsToM4A(filepath string, lyrics string) error {
|
|
|
|
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
|
if err != nil {
|
|
fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
|
validatedLyrics = lyrics
|
|
}
|
|
lyrics = validatedLyrics
|
|
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
|
}
|
|
|
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
|
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
|
}
|
|
|
|
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
|
|
defer func() {
|
|
|
|
if _, err := os.Stat(tmpOutputFile); err == nil {
|
|
os.Remove(tmpOutputFile)
|
|
}
|
|
}()
|
|
|
|
cmd := exec.Command(ffmpegPath,
|
|
"-i", filepath,
|
|
"-map", "0",
|
|
"-map_metadata", "0",
|
|
"-metadata", "lyrics-eng="+lyrics,
|
|
"-metadata", "lyrics="+lyrics,
|
|
"-codec", "copy",
|
|
"-f", "ipod",
|
|
"-y",
|
|
tmpOutputFile,
|
|
)
|
|
|
|
setHideWindow(cmd)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
|
|
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
|
|
}
|
|
|
|
if err := os.Rename(tmpOutputFile, filepath); err != nil {
|
|
return fmt.Errorf("failed to replace original file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
|
|
return nil
|
|
}
|
|
|
|
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
|
if lyrics == "" {
|
|
return nil
|
|
}
|
|
|
|
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
|
if err != nil {
|
|
fmt.Printf("[EmbedLyricsOnlyUniversal] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
|
validatedLyrics = lyrics
|
|
}
|
|
lyrics = validatedLyrics
|
|
|
|
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
|
switch ext {
|
|
case ".mp3":
|
|
return EmbedLyricsOnlyMP3(filepath, lyrics)
|
|
case ".flac":
|
|
return EmbedLyricsOnly(filepath, lyrics)
|
|
case ".m4a":
|
|
return embedLyricsToM4A(filepath, lyrics)
|
|
default:
|
|
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
|
|
}
|
|
}
|
|
|
|
func GetAudioDuration(filepath string) (float64, error) {
|
|
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
|
|
|
if ext == ".flac" {
|
|
duration, err := getFlacDuration(filepath)
|
|
if err == nil && duration > 0 {
|
|
return duration, nil
|
|
}
|
|
}
|
|
|
|
return getDurationWithFFprobe(filepath)
|
|
}
|
|
|
|
func getFlacDuration(filepath string) (float64, error) {
|
|
f, err := flac.ParseFile(filepath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if len(f.Meta) > 0 {
|
|
streamInfo := f.Meta[0]
|
|
if streamInfo.Type == flac.StreamInfo {
|
|
data := streamInfo.Data
|
|
if len(data) >= 18 {
|
|
|
|
sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
|
|
|
totalSamples := uint64(data[13]&0x0F)<<32 |
|
|
uint64(data[14])<<24 |
|
|
uint64(data[15])<<16 |
|
|
uint64(data[16])<<8 |
|
|
uint64(data[17])
|
|
|
|
if sampleRate > 0 {
|
|
return float64(totalSamples) / float64(sampleRate), nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0, fmt.Errorf("could not extract duration from FLAC file")
|
|
}
|
|
|
|
func getDurationWithFFprobe(filepath string) (float64, error) {
|
|
ffprobePath, err := GetFFprobePath()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
|
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
|
|
}
|
|
|
|
cmd := exec.Command(ffprobePath,
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
filepath,
|
|
)
|
|
|
|
setHideWindow(cmd)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var result struct {
|
|
Format struct {
|
|
Duration string `json:"duration"`
|
|
} `json:"format"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if result.Format.Duration == "" {
|
|
return 0, fmt.Errorf("duration not found in ffprobe output")
|
|
}
|
|
|
|
duration, err := strconv.ParseFloat(result.Format.Duration, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return duration, nil
|
|
}
|
|
|
|
func validateLyricsDuration(lyrics string, filepath string) (string, error) {
|
|
|
|
duration, err := GetAudioDuration(filepath)
|
|
if err != nil {
|
|
|
|
fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err)
|
|
return lyrics, nil
|
|
}
|
|
|
|
if duration <= 0 {
|
|
|
|
fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration)
|
|
return lyrics, nil
|
|
}
|
|
|
|
durationMs := int64(duration * 1000)
|
|
|
|
lines := strings.Split(lyrics, "\n")
|
|
var validLines []string
|
|
|
|
for _, line := range lines {
|
|
trimmedLine := strings.TrimSpace(line)
|
|
if trimmedLine == "" {
|
|
validLines = append(validLines, line)
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(trimmedLine, "[") {
|
|
|
|
closeBracket := strings.Index(trimmedLine, "]")
|
|
if closeBracket > 0 {
|
|
timestampStr := trimmedLine[1:closeBracket]
|
|
|
|
ms := parseLRCTimestamp(timestampStr)
|
|
if ms >= 0 {
|
|
if ms <= durationMs {
|
|
validLines = append(validLines, line)
|
|
} else {
|
|
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
|
|
}
|
|
} else {
|
|
|
|
validLines = append(validLines, line)
|
|
}
|
|
continue
|
|
}
|
|
} else {
|
|
|
|
validLines = append(validLines, line)
|
|
}
|
|
}
|
|
|
|
return strings.Join(validLines, "\n"), nil
|
|
}
|
|
|
|
func parseLRCTimestamp(timestamp string) int64 {
|
|
var minutes, seconds, centiseconds int64
|
|
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
|
if n >= 2 {
|
|
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
|
filePath = norm.NFC.String(filePath)
|
|
var metadata Metadata
|
|
|
|
ffprobePath, err := GetFFprobePath()
|
|
if err != nil {
|
|
return metadata, err
|
|
}
|
|
|
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
|
return metadata, 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 metadata, err
|
|
}
|
|
|
|
var result 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, &result); err != nil {
|
|
return metadata, err
|
|
}
|
|
|
|
allTags := make(map[string]string)
|
|
|
|
for _, stream := range result.Streams {
|
|
for key, value := range stream.Tags {
|
|
allTags[strings.ToLower(key)] = value
|
|
}
|
|
}
|
|
|
|
for key, value := range result.Format.Tags {
|
|
allTags[strings.ToLower(key)] = value
|
|
}
|
|
|
|
for key, value := range allTags {
|
|
switch key {
|
|
case "title":
|
|
metadata.Title = value
|
|
case "artist":
|
|
metadata.Artist = value
|
|
case "album":
|
|
metadata.Album = value
|
|
case "album_artist", "albumartist":
|
|
metadata.AlbumArtist = value
|
|
case "date", "year":
|
|
if metadata.Date == "" || len(value) > len(metadata.Date) {
|
|
metadata.Date = value
|
|
}
|
|
case "track":
|
|
|
|
parts := strings.Split(value, "/")
|
|
if len(parts) > 0 {
|
|
if num, err := strconv.Atoi(parts[0]); err == nil {
|
|
metadata.TrackNumber = num
|
|
}
|
|
}
|
|
if len(parts) > 1 {
|
|
if num, err := strconv.Atoi(parts[1]); err == nil {
|
|
metadata.TotalTracks = num
|
|
}
|
|
}
|
|
case "disc":
|
|
|
|
parts := strings.Split(value, "/")
|
|
if len(parts) > 0 {
|
|
if num, err := strconv.Atoi(parts[0]); err == nil {
|
|
metadata.DiscNumber = num
|
|
}
|
|
}
|
|
if len(parts) > 1 {
|
|
if num, err := strconv.Atoi(parts[1]); err == nil {
|
|
metadata.TotalDiscs = num
|
|
}
|
|
}
|
|
case "copyright", "tcop":
|
|
metadata.Copyright = value
|
|
case "publisher", "tpub", "label":
|
|
metadata.Publisher = value
|
|
case "url":
|
|
metadata.URL = value
|
|
case "description", "comment":
|
|
if metadata.Description == "" {
|
|
metadata.Description = value
|
|
}
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
|
filePath = norm.NFC.String(filePath)
|
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
|
|
|
switch ext {
|
|
case ".flac":
|
|
|
|
return EmbedMetadata(filePath, metadata, coverPath)
|
|
case ".mp3":
|
|
return embedMetadataToMP3(filePath, metadata, coverPath)
|
|
case ".m4a":
|
|
return embedMetadataToM4A(filePath, metadata, coverPath)
|
|
default:
|
|
return fmt.Errorf("unsupported file format: %s", ext)
|
|
}
|
|
}
|
|
|
|
func embedMetadataToMP3(filePath string, metadata Metadata, coverPath 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()
|
|
|
|
tag.DeleteFrames("TXXX")
|
|
|
|
if metadata.Title != "" {
|
|
tag.SetTitle(metadata.Title)
|
|
}
|
|
if metadata.Artist != "" {
|
|
tag.SetArtist(metadata.Artist)
|
|
}
|
|
if metadata.Album != "" {
|
|
tag.SetAlbum(metadata.Album)
|
|
}
|
|
if metadata.Date != "" {
|
|
year := metadata.Date
|
|
if len(year) >= 4 {
|
|
year = year[:4]
|
|
}
|
|
tag.SetYear(year)
|
|
}
|
|
|
|
if metadata.AlbumArtist != "" {
|
|
tag.DeleteFrames("TPE2")
|
|
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
|
|
}
|
|
|
|
if metadata.TrackNumber > 0 {
|
|
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
|
|
trackStr := strconv.Itoa(metadata.TrackNumber)
|
|
if metadata.TotalTracks > 0 {
|
|
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
|
|
}
|
|
tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr)
|
|
}
|
|
|
|
if metadata.DiscNumber > 0 {
|
|
tag.DeleteFrames(tag.CommonID("Part of a set"))
|
|
discStr := strconv.Itoa(metadata.DiscNumber)
|
|
if metadata.TotalDiscs > 0 {
|
|
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
|
|
}
|
|
tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr)
|
|
}
|
|
|
|
if metadata.Copyright != "" {
|
|
tag.DeleteFrames("TCOP")
|
|
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
|
|
}
|
|
|
|
if metadata.Publisher != "" {
|
|
tag.DeleteFrames("TPUB")
|
|
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
|
|
}
|
|
|
|
if metadata.ISRC != "" {
|
|
tag.DeleteFrames("TSRC")
|
|
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
|
|
}
|
|
|
|
if coverPath != "" && fileExists(coverPath) {
|
|
|
|
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
|
|
|
artwork, err := os.ReadFile(coverPath)
|
|
if err == nil {
|
|
pic := id3v2.PictureFrame{
|
|
Encoding: id3v2.EncodingUTF8,
|
|
MimeType: "image/jpeg",
|
|
PictureType: id3v2.PTFrontCover,
|
|
Description: "Cover",
|
|
Picture: artwork,
|
|
}
|
|
tag.AddAttachedPicture(pic)
|
|
} else {
|
|
fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err)
|
|
}
|
|
}
|
|
|
|
if err := tag.Save(); err != nil {
|
|
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error {
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
|
}
|
|
|
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
|
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
|
}
|
|
|
|
args := []string{
|
|
"-i", filePath,
|
|
"-y",
|
|
}
|
|
|
|
if coverPath != "" && fileExists(coverPath) {
|
|
args = append(args, "-i", coverPath)
|
|
args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
|
} else {
|
|
args = append(args, "-map", "0", "-codec", "copy")
|
|
}
|
|
|
|
if metadata.Title != "" {
|
|
args = append(args, "-metadata", "title="+metadata.Title)
|
|
}
|
|
if metadata.Artist != "" {
|
|
args = append(args, "-metadata", "artist="+metadata.Artist)
|
|
}
|
|
if metadata.Album != "" {
|
|
args = append(args, "-metadata", "album="+metadata.Album)
|
|
}
|
|
if metadata.AlbumArtist != "" {
|
|
args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist)
|
|
}
|
|
if metadata.Date != "" {
|
|
args = append(args, "-metadata", "date="+metadata.Date)
|
|
}
|
|
if metadata.TrackNumber > 0 {
|
|
trackStr := strconv.Itoa(metadata.TrackNumber)
|
|
if metadata.TotalTracks > 0 {
|
|
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
|
|
}
|
|
args = append(args, "-metadata", "track="+trackStr)
|
|
}
|
|
if metadata.DiscNumber > 0 {
|
|
discStr := strconv.Itoa(metadata.DiscNumber)
|
|
if metadata.TotalDiscs > 0 {
|
|
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
|
|
}
|
|
args = append(args, "-metadata", "disk="+discStr)
|
|
}
|
|
if metadata.Copyright != "" {
|
|
args = append(args, "-metadata", "copyright="+metadata.Copyright)
|
|
}
|
|
if metadata.Publisher != "" {
|
|
args = append(args, "-metadata", "publisher="+metadata.Publisher)
|
|
}
|
|
if metadata.ISRC != "" {
|
|
args = append(args, "-metadata", "isrc="+metadata.ISRC)
|
|
}
|
|
|
|
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
|
|
defer func() {
|
|
if _, err := os.Stat(tmpOutputFile); err == nil {
|
|
os.Remove(tmpOutputFile)
|
|
}
|
|
}()
|
|
|
|
args = append(args, "-f", "ipod", tmpOutputFile)
|
|
|
|
cmd := exec.Command(ffmpegPath, args...)
|
|
setHideWindow(cmd)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err)
|
|
}
|
|
|
|
if err := os.Rename(tmpOutputFile, filePath); err != nil {
|
|
return fmt.Errorf("failed to replace original file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|