545 lines
14 KiB
Go
545 lines
14 KiB
Go
package backend
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ulikunitz/xz"
|
|
)
|
|
|
|
// decodeBase64 decodes a base64 encoded string
|
|
func decodeBase64(encoded string) (string, error) {
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(decoded), nil
|
|
}
|
|
|
|
const (
|
|
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
|
|
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
|
|
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZm1wZWcvemlw"
|
|
)
|
|
|
|
// GetFFmpegDir returns the directory where ffmpeg should be stored
|
|
func GetFFmpegDir() (string, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
|
}
|
|
return filepath.Join(homeDir, ".spotiflac"), nil
|
|
}
|
|
|
|
// GetFFmpegPath returns the full path to the ffmpeg executable
|
|
func GetFFmpegPath() (string, error) {
|
|
ffmpegDir, err := GetFFmpegDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ffmpegName := "ffmpeg"
|
|
if runtime.GOOS == "windows" {
|
|
ffmpegName = "ffmpeg.exe"
|
|
}
|
|
|
|
return filepath.Join(ffmpegDir, ffmpegName), nil
|
|
}
|
|
|
|
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
|
|
func IsFFmpegInstalled() (bool, error) {
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
_, err = os.Stat(ffmpegPath)
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Verify it's executable
|
|
cmd := exec.Command(ffmpegPath, "-version")
|
|
err = cmd.Run()
|
|
return err == nil, nil
|
|
}
|
|
|
|
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
|
|
func DownloadFFmpeg(progressCallback func(int)) error {
|
|
ffmpegDir, err := GetFFmpegDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create directory if it doesn't exist
|
|
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
|
}
|
|
|
|
// Get the appropriate URL for the current OS
|
|
var encodedURL string
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
encodedURL = ffmpegWindowsURL
|
|
case "linux":
|
|
encodedURL = ffmpegLinuxURL
|
|
case "darwin":
|
|
encodedURL = ffmpegMacOSURL
|
|
default:
|
|
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
|
}
|
|
|
|
// Decode URL
|
|
url, err := decodeBase64(encodedURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
|
|
|
|
// Create temporary file for download
|
|
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
defer tmpFile.Close()
|
|
|
|
// Download the file
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download ffmpeg: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("failed to download ffmpeg: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
totalSize := resp.ContentLength
|
|
var downloaded int64
|
|
|
|
// Create a progress reader
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, err := resp.Body.Read(buf)
|
|
if n > 0 {
|
|
_, writeErr := tmpFile.Write(buf[:n])
|
|
if writeErr != nil {
|
|
return fmt.Errorf("failed to write to temp file: %w", writeErr)
|
|
}
|
|
downloaded += int64(n)
|
|
if totalSize > 0 && progressCallback != nil {
|
|
progress := int(float64(downloaded) / float64(totalSize) * 100)
|
|
progressCallback(progress)
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
}
|
|
|
|
tmpFile.Close()
|
|
|
|
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
|
|
|
|
// Extract the archive
|
|
switch runtime.GOOS {
|
|
case "windows", "darwin":
|
|
return extractZip(tmpFile.Name(), ffmpegDir)
|
|
case "linux":
|
|
return extractTarXz(tmpFile.Name(), ffmpegDir)
|
|
default:
|
|
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
|
}
|
|
}
|
|
|
|
// extractZip extracts ffmpeg from a zip archive
|
|
func extractZip(zipPath, destDir string) error {
|
|
r, err := zip.OpenReader(zipPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open zip: %w", err)
|
|
}
|
|
defer r.Close()
|
|
|
|
ffmpegName := "ffmpeg"
|
|
if runtime.GOOS == "windows" {
|
|
ffmpegName = "ffmpeg.exe"
|
|
}
|
|
|
|
destPath := filepath.Join(destDir, ffmpegName)
|
|
|
|
for _, f := range r.File {
|
|
// Look for ffmpeg executable in any subdirectory
|
|
baseName := filepath.Base(f.Name)
|
|
if baseName == ffmpegName && !f.FileInfo().IsDir() {
|
|
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
|
|
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file in zip: %w", err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output file: %w", err)
|
|
}
|
|
defer outFile.Close()
|
|
|
|
_, err = io.Copy(outFile, rc)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to extract file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("ffmpeg executable not found in archive")
|
|
}
|
|
|
|
// extractTarXz extracts ffmpeg from a tar.xz archive
|
|
func extractTarXz(tarXzPath, destDir string) error {
|
|
file, err := os.Open(tarXzPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open tar.xz: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
xzReader, err := xz.NewReader(file)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create xz reader: %w", err)
|
|
}
|
|
|
|
tarReader := tar.NewReader(xzReader)
|
|
|
|
ffmpegName := "ffmpeg"
|
|
destPath := filepath.Join(destDir, ffmpegName)
|
|
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read tar: %w", err)
|
|
}
|
|
|
|
baseName := filepath.Base(header.Name)
|
|
if baseName == ffmpegName && header.Typeflag == tar.TypeReg {
|
|
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
|
|
|
|
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output file: %w", err)
|
|
}
|
|
defer outFile.Close()
|
|
|
|
_, err = io.Copy(outFile, tarReader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to extract file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("ffmpeg executable not found in archive")
|
|
}
|
|
|
|
// ConvertAudioRequest represents a request to convert audio files
|
|
type ConvertAudioRequest struct {
|
|
InputFiles []string `json:"input_files"`
|
|
OutputFormat string `json:"output_format"` // mp3, m4a
|
|
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k"
|
|
}
|
|
|
|
// ConvertAudioResult represents the result of a single file conversion
|
|
type ConvertAudioResult struct {
|
|
InputFile string `json:"input_file"`
|
|
OutputFile string `json:"output_file"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// ConvertAudio converts audio files using ffmpeg while preserving metadata
|
|
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
|
}
|
|
|
|
installed, err := IsFFmpegInstalled()
|
|
if err != nil || !installed {
|
|
return nil, fmt.Errorf("ffmpeg is not installed")
|
|
}
|
|
|
|
results := make([]ConvertAudioResult, len(req.InputFiles))
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
|
|
// Convert files in parallel
|
|
for i, inputFile := range req.InputFiles {
|
|
wg.Add(1)
|
|
go func(idx int, inputFile string) {
|
|
defer wg.Done()
|
|
|
|
result := ConvertAudioResult{
|
|
InputFile: inputFile,
|
|
}
|
|
|
|
// Get input file info
|
|
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
|
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
|
inputDir := filepath.Dir(inputFile)
|
|
|
|
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
|
|
outputFormatUpper := strings.ToUpper(req.OutputFormat)
|
|
outputDir := filepath.Join(inputDir, outputFormatUpper)
|
|
|
|
// Create output directory if it doesn't exist
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
|
result.Success = false
|
|
mu.Lock()
|
|
results[idx] = result
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Determine output path
|
|
outputExt := "." + strings.ToLower(req.OutputFormat)
|
|
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
|
|
|
// Skip if same format
|
|
if inputExt == outputExt {
|
|
result.Error = "Input and output formats are the same"
|
|
result.Success = false
|
|
mu.Lock()
|
|
results[idx] = result
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
|
|
result.OutputFile = outputFile
|
|
|
|
// Extract cover art and lyrics from input file before conversion
|
|
var coverArtPath string
|
|
var lyrics string
|
|
|
|
coverArtPath, _ = ExtractCoverArt(inputFile)
|
|
lyrics, _ = ExtractLyrics(inputFile)
|
|
|
|
// Build ffmpeg command
|
|
args := []string{
|
|
"-i", inputFile,
|
|
"-y", // Overwrite output
|
|
}
|
|
|
|
// Add codec and bitrate based on output format
|
|
switch req.OutputFormat {
|
|
case "mp3":
|
|
args = append(args,
|
|
"-codec:a", "libmp3lame",
|
|
"-b:a", req.Bitrate,
|
|
"-map", "0:a", // Map audio stream
|
|
"-map_metadata", "0", // Copy all metadata
|
|
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
|
|
)
|
|
// Map video stream if exists (for cover art)
|
|
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
|
case "m4a":
|
|
args = append(args,
|
|
"-codec:a", "aac",
|
|
"-b:a", req.Bitrate,
|
|
"-map", "0:a", // Map audio stream
|
|
"-map_metadata", "0", // Copy all metadata
|
|
)
|
|
// Map video stream for cover art in M4A
|
|
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
|
}
|
|
|
|
args = append(args, outputFile)
|
|
|
|
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
|
|
|
|
cmd := exec.Command(ffmpegPath, args...)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
|
|
result.Success = false
|
|
mu.Lock()
|
|
results[idx] = result
|
|
mu.Unlock()
|
|
// Clean up temp cover art file if exists
|
|
if coverArtPath != "" {
|
|
os.Remove(coverArtPath)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Embed cover art and lyrics after conversion if they were extracted
|
|
if coverArtPath != "" {
|
|
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil {
|
|
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err)
|
|
} else {
|
|
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
|
|
}
|
|
os.Remove(coverArtPath) // Clean up temp file
|
|
}
|
|
|
|
if lyrics != "" {
|
|
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
|
|
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
|
|
} else {
|
|
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
|
|
}
|
|
}
|
|
|
|
result.Success = true
|
|
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
|
|
|
|
mu.Lock()
|
|
results[idx] = result
|
|
mu.Unlock()
|
|
}(i, inputFile)
|
|
}
|
|
|
|
wg.Wait()
|
|
return results, nil
|
|
}
|
|
|
|
// GetAudioInfo returns information about an audio file
|
|
type AudioFileInfo struct {
|
|
Path string `json:"path"`
|
|
Filename string `json:"filename"`
|
|
Format string `json:"format"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// GetAudioFileInfo gets information about an audio file
|
|
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
|
|
return &AudioFileInfo{
|
|
Path: filePath,
|
|
Filename: filepath.Base(filePath),
|
|
Format: ext,
|
|
Size: info.Size(),
|
|
}, nil
|
|
}
|
|
|
|
// InstallFFmpegFromFile installs ffmpeg from a local file path
|
|
func InstallFFmpegFromFile(filePath string) error {
|
|
// Check if file exists
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("file does not exist: %w", err)
|
|
}
|
|
|
|
// Check if it's a regular file (not a directory)
|
|
if info.IsDir() {
|
|
return fmt.Errorf("path is a directory, not a file")
|
|
}
|
|
|
|
// Verify it's likely an ffmpeg executable by checking the filename
|
|
fileName := strings.ToLower(filepath.Base(filePath))
|
|
expectedName := "ffmpeg"
|
|
if runtime.GOOS == "windows" {
|
|
expectedName = "ffmpeg.exe"
|
|
}
|
|
|
|
if fileName != expectedName && !strings.Contains(fileName, "ffmpeg") {
|
|
return fmt.Errorf("file does not appear to be an ffmpeg executable (expected name containing 'ffmpeg')")
|
|
}
|
|
|
|
// Get destination path
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get ffmpeg path: %w", err)
|
|
}
|
|
|
|
ffmpegDir := filepath.Dir(ffmpegPath)
|
|
|
|
// Create directory if it doesn't exist
|
|
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
|
}
|
|
|
|
// Copy file to destination
|
|
sourceFile, err := os.Open(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open source file: %w", err)
|
|
}
|
|
|
|
destFile, err := os.OpenFile(ffmpegPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
sourceFile.Close()
|
|
return fmt.Errorf("failed to create destination file: %w", err)
|
|
}
|
|
|
|
_, err = io.Copy(destFile, sourceFile)
|
|
sourceFile.Close()
|
|
if err != nil {
|
|
destFile.Close()
|
|
return fmt.Errorf("failed to copy file: %w", err)
|
|
}
|
|
|
|
// Ensure all data is written to disk
|
|
if err := destFile.Sync(); err != nil {
|
|
destFile.Close()
|
|
return fmt.Errorf("failed to sync file: %w", err)
|
|
}
|
|
destFile.Close()
|
|
|
|
// On Windows, file may still be locked by antivirus or system
|
|
// Wait a bit and retry verification
|
|
maxRetries := 3
|
|
retryDelay := 500 * time.Millisecond
|
|
|
|
var verifyErr error
|
|
for i := 0; i < maxRetries; i++ {
|
|
if i > 0 {
|
|
time.Sleep(retryDelay)
|
|
}
|
|
|
|
cmd := exec.Command(ffmpegPath, "-version")
|
|
verifyErr = cmd.Run()
|
|
if verifyErr == nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
if verifyErr != nil {
|
|
return fmt.Errorf("file copied but ffmpeg verification failed after %d attempts: %w", maxRetries, verifyErr)
|
|
}
|
|
|
|
fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath)
|
|
return nil
|
|
}
|
|
|