This commit is contained in:
afkarxyz
2026-01-11 08:39:14 +07:00
parent cb6dfc1638
commit 9260adc2d2
97 changed files with 9452 additions and 12379 deletions
+25 -54
View File
@@ -63,16 +63,14 @@ func (a *AmazonDownloader) getRandomUserAgent() string {
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
// Reset counter every minute
now := time.Now()
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
@@ -82,10 +80,9 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
}
// Add delay between requests (6 seconds = 10 requests per minute)
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second // 7 seconds to be safe
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
@@ -93,7 +90,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -109,7 +105,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
fmt.Println("Getting Amazon URL...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -118,11 +113,10 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
// Update rate limit tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
if resp.StatusCode == 429 { // Too Many Requests
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
@@ -142,7 +136,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
defer resp.Body.Close()
// Read body first to handle encoding issues and provide better error messages
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
@@ -154,7 +147,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
var songLinkResp SongLinkResponse
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -169,7 +162,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if strings.Contains(amazonURL, "trackAsin=") {
parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 {
@@ -188,12 +180,11 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
for _, region := range a.regions {
fmt.Printf("\nTrying region: %s...\n", region)
// Decode base64 service URL
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
@@ -234,7 +225,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
downloadID := submitResp.ID
fmt.Printf("Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("Waiting for download to complete...")
@@ -276,7 +266,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
if status.Status == "done" {
fmt.Println("\nDownload ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
@@ -289,7 +278,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
// Download file
downloadReq, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create download request: %w", err)
@@ -310,7 +298,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
break
}
// Generate filename
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
for _, char := range `<>:"/\|?*` {
fileName = strings.ReplaceAll(fileName, string(char), "")
@@ -319,7 +306,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
filePath := filepath.Join(outputDir, fileName)
// Save file
out, err := os.Create(filePath)
if err != nil {
lastError = fmt.Errorf("failed to create file: %w", err)
@@ -328,7 +314,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, fileResp.Body)
if err != nil {
@@ -336,7 +322,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete!")
return filePath, nil
@@ -349,7 +334,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
// Still processing
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
@@ -372,15 +357,14 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
// Create output directory if needed
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
@@ -393,29 +377,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
// Download from service
filePath, err := a.DownloadFromService(amazonURL, outputDir)
if err != nil {
return "", err
}
// Rename file based on Spotify metadata
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Extract year from release date
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
// Build filename based on format settings
var newFilename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
@@ -424,34 +403,31 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
// Handle disc number
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
} else {
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default: // "title-artist"
default:
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
@@ -460,7 +436,6 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = newFilename + ".flac"
newFilePath := filepath.Join(outputDir, newFilename)
// Rename file
if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else {
@@ -469,11 +444,10 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
}
}
// Embed Spotify metadata (replace Amazon's embedded metadata)
fmt.Println("Embedding Spotify metadata...")
coverPath := ""
// Download Spotify cover (with max resolution if enabled)
if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient()
@@ -486,27 +460,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
}
}
// Determine track number to embed
// Use Spotify track number (album track number) if available, otherwise use position
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = position // Fallback to playlist position
}
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
trackNumberToEmbed = 1
}
// Build metadata from Spotify
metadata := Metadata{
Title: spotifyTrackName,
Artist: spotifyArtistName,
Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
ISRC: spotifyISRC, // Use ISRC from Spotify
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
@@ -521,12 +492,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
return filePath, nil
}
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
// Get Amazon URL from Spotify track ID
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
}
+5 -30
View File
@@ -9,7 +9,6 @@ import (
mewflac "github.com/mewkiz/flac"
)
// AnalysisResult contains the audio analysis data
type AnalysisResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
@@ -25,19 +24,16 @@ type AnalysisResult struct {
Spectrum *SpectrumData `json:"spectrum,omitempty"`
}
// AnalyzeTrack performs audio analysis on a FLAC file
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
// Get file size
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
// Parse FLAC file
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
@@ -48,68 +44,55 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
FileSize: fileInfo.Size(),
}
// Extract basic audio properties from STREAMINFO block
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
// Read STREAMINFO data
data := streamInfo.Data
if len(data) >= 18 {
// Sample rate (bits 10-29 of bytes 10-13)
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
// Channels (bits 30-32 of byte 12)
result.Channels = ((data[12] >> 1) & 0x07) + 1
// Bits per sample (bits 33-37 of bytes 12-13)
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
// Total samples (bits 38-73 of bytes 13-17)
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
// Calculate duration
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
// Read min/max frame size and block size for additional analysis
// Min block size (bytes 0-1)
// Max block size (bytes 2-3)
// These can give us hints about encoding quality
}
}
}
// Analyze spectrum and calculate real audio metrics
spectrum, err := AnalyzeSpectrum(filepath)
if err != nil {
// Log error but continue
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else {
result.Spectrum = spectrum
// Calculate dynamic range, peak, and RMS from decoded samples
calculateRealAudioMetrics(result, filepath)
}
// Set bit depth
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
// Decode FLAC to get actual samples
samples, err := decodeFLACForMetrics(filepath)
if err != nil {
return
}
// Calculate peak amplitude
var peak float64
var sumSquares float64
@@ -124,20 +107,16 @@ func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
sumSquares += sample * sample
}
// Convert peak to dB (reference: 1.0 = 0 dBFS)
peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB
// Calculate RMS (Root Mean Square)
rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB
// Dynamic range is the difference between peak and RMS
result.DynamicRange = peakDB - rmsDB
}
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath)
if err != nil {
@@ -145,24 +124,20 @@ func decodeFLACForMetrics(filepath string) ([]float64, error) {
}
defer stream.Close()
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
maxSamples := 10000000
samples := make([]float64, 0, maxSamples)
// Read all audio frames
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
// Get samples from first channel (mono or left channel)
var channelSamples []int32
if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples
}
// Normalize samples to -1.0 to 1.0 range
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
for _, sample := range channelSamples {
if len(samples) >= maxSamples {
+2 -3
View File
@@ -6,13 +6,12 @@ import (
)
func GetDefaultMusicPath() string {
// Get user's home directory
homeDir, err := os.UserHomeDir()
if err != nil {
// Fallback to Public Music if can't get home dir
return "C:\\Users\\Public\\Music"
}
// Return path to user's Music folder
return filepath.Join(homeDir, "Music")
}
+303 -46
View File
@@ -12,12 +12,10 @@ import (
)
const (
// Spotify image size codes
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySizeMax = "ab67616d000082c1" // Max resolution
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
// CoverDownloadRequest represents a request to download cover art
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
@@ -32,7 +30,6 @@ type CoverDownloadRequest struct {
DiscNumber int `json:"disc_number"`
}
// CoverDownloadResponse represents the response from cover download
type CoverDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
@@ -41,26 +38,36 @@ type CoverDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
}
// CoverClient handles cover art downloading
type HeaderDownloadRequest struct {
HeaderURL string `json:"header_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type HeaderDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
type CoverClient struct {
httpClient *http.Client
}
// NewCoverClient creates a new cover client
func NewCoverClient() *CoverClient {
return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// buildCoverFilename builds the cover filename based on settings (same as track filename)
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
@@ -68,7 +75,6 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
@@ -77,72 +83,58 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title-artist":
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
filename = fmt.Sprintf("%02d - %s", position, filename)
}
}
return filename + ".jpg"
return filename + ".cover.jpg"
}
// getMaxResolutionURL converts a Spotify cover URL to max resolution
// Falls back to original URL if max resolution is not available
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
// Try to convert to max resolution
if strings.Contains(coverURL, spotifySize640) {
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Check if max resolution URL is available
resp, err := c.httpClient.Head(maxURL)
if err == nil && resp.StatusCode == http.StatusOK {
return maxURL
}
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
if strings.Contains(imageURL, spotifySize640) {
return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
}
// Return original URL as fallback
return coverURL
return imageURL
}
// DownloadCoverToPath downloads cover art from URL to a specific path
// If embedMaxQualityCover is true, it will try to get max resolution
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
if coverURL == "" {
return fmt.Errorf("cover URL is required")
}
// Use max quality URL if setting is enabled
downloadURL := coverURL
if embedMaxQualityCover {
downloadURL = c.getMaxResolutionURL(coverURL)
}
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("failed to download cover: %v", err)
@@ -153,14 +145,12 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
}
// Create file
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %v", err)
}
defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write cover file: %v", err)
@@ -169,7 +159,6 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
return nil
}
// DownloadCover downloads cover art for a single track
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" {
return &CoverDownloadResponse{
@@ -178,7 +167,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("cover URL is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
@@ -193,15 +181,13 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
filenameFormat = "title-artist"
}
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{
Success: true,
@@ -211,10 +197,8 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, nil
}
// Try to get max resolution URL, fallback to original
downloadURL := c.getMaxResolutionURL(req.CoverURL)
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return &CoverDownloadResponse{
@@ -231,7 +215,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Create file
file, err := os.Create(filePath)
if err != nil {
return &CoverDownloadResponse{
@@ -241,7 +224,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}
defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return &CoverDownloadResponse{
@@ -256,3 +238,278 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
File: filePath,
}, nil
}
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
if req.HeaderURL == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Header URL is required",
}, fmt.Errorf("header URL is required")
}
if req.ArtistName == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &HeaderDownloadResponse{
Success: true,
Message: "Header file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.HeaderURL)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write header file: %v", err),
}, err
}
return &HeaderDownloadResponse{
Success: true,
Message: "Header downloaded successfully",
File: filePath,
}, nil
}
type GalleryImageDownloadRequest struct {
ImageURL string `json:"image_url"`
ArtistName string `json:"artist_name"`
ImageIndex int `json:"image_index"`
OutputDir string `json:"output_dir"`
}
type GalleryImageDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
if req.ImageURL == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Image URL is required",
}, fmt.Errorf("image URL is required")
}
if req.ArtistName == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.ImageURL)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
}, err
}
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image downloaded successfully",
File: filePath,
}, nil
}
type AvatarDownloadRequest struct {
AvatarURL string `json:"avatar_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type AvatarDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
if req.AvatarURL == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Avatar URL is required",
}, fmt.Errorf("avatar URL is required")
}
if req.ArtistName == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.AvatarURL)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write avatar file: %v", err),
}, err
}
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar downloaded successfully",
File: filePath,
}, nil
}
+95 -77
View File
@@ -13,11 +13,11 @@ import (
"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 {
@@ -26,14 +26,12 @@ func decodeBase64(encoded string) (string, error) {
return string(decoded), nil
}
// ValidateExecutable checks if the path points to a valid, safe executable
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
// Ensure path is absolute
if !filepath.IsAbs(cleanedPath) {
return fmt.Errorf("path must be absolute: %s", path)
}
@@ -47,14 +45,12 @@ func ValidateExecutable(path string) error {
return fmt.Errorf("path is a directory: %s", path)
}
// Check executable permission on Unix
if runtime.GOOS != "windows" {
if info.Mode()&0111 == 0 {
return fmt.Errorf("file is not executable: %s", path)
}
}
// Validate filename allowlist
base := filepath.Base(cleanedPath)
validNames := map[string]bool{
"ffmpeg": true,
@@ -76,7 +72,6 @@ const (
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
// GetFFmpegDir returns the directory where ffmpeg should be stored
func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -85,7 +80,6 @@ func GetFFmpegDir() (string, error) {
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 {
@@ -100,7 +94,6 @@ func GetFFmpegPath() (string, error) {
return filepath.Join(ffmpegDir, ffmpegName), nil
}
// GetFFprobePath returns the full path to the ffprobe executable in app directory
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
@@ -120,7 +113,6 @@ func GetFFprobePath() (string, error) {
return "", fmt.Errorf("ffprobe not found in app directory")
}
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
@@ -131,14 +123,12 @@ func IsFFprobeInstalled() (bool, error) {
return false, nil
}
// Verify it's executable
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
@@ -149,33 +139,35 @@ func IsFFmpegInstalled() (bool, error) {
return false, nil
}
// Verify it's executable
cmd := exec.Command(ffmpegPath, "-version")
// Hide console window on Windows
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0)
SetDownloadSpeed(0)
SetDownloading(true)
defer SetDownloading(false)
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)
}
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled {
// Download both
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
@@ -188,14 +180,14 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
} else if !ffmpegInstalled {
// Only download ffmpeg
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
// Only download ffprobe
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
@@ -205,7 +197,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil
}
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
var encodedURL string
switch runtime.GOOS {
case "windows":
@@ -216,7 +207,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
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)
@@ -231,9 +221,8 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil
}
// downloadAndExtract downloads a file and extracts it
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
// Create temporary file for download
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
@@ -241,7 +230,6 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
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: %w", err)
@@ -254,8 +242,16 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
totalSize := resp.ContentLength
var downloaded int64
lastTime := time.Now()
var lastBytes int64
if totalSize > 0 {
totalSizeMB := float64(totalSize) / (1024 * 1024)
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
} else {
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
}
// Create a progress reader
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
@@ -265,12 +261,46 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
return fmt.Errorf("failed to write to temp file: %w", writeErr)
}
downloaded += int64(n)
mbDownloaded := float64(downloaded) / (1024 * 1024)
now := time.Now()
timeDiff := now.Sub(lastTime).Seconds()
var speedMBps float64
if timeDiff > 0.1 {
bytesDiff := float64(downloaded - lastBytes)
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
lastTime = now
lastBytes = downloaded
}
SetDownloadProgress(mbDownloaded)
if speedMBps > 0 {
SetDownloadSpeed(speedMBps)
}
if totalSize > 0 && progressCallback != nil {
// Scale progress between progressStart and progressEnd
rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
}
if totalSize > 0 {
percent := float64(downloaded) * 100 / float64(totalSize)
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
mbDownloaded, float64(totalSize)/(1024*1024), percent)
}
} else {
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
}
}
}
if err == io.EOF {
break
@@ -282,16 +312,20 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
tmpFile.Close()
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
if totalSize > 0 {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
} else {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
}
fmt.Printf("[FFmpeg] Extracting...\n")
// Extract the archive based on file type
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir)
}
return extractZip(tmpFile.Name(), destDir)
}
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
@@ -323,7 +357,7 @@ func extractZip(zipPath, destDir string) error {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
@@ -351,7 +385,6 @@ func extractZip(zipPath, destDir string) error {
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
@@ -366,7 +399,6 @@ func extractZip(zipPath, destDir string) error {
return nil
}
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath)
if err != nil {
@@ -409,7 +441,7 @@ func extractTarXz(tarXzPath, destDir string) error {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
@@ -430,7 +462,6 @@ func extractTarXz(tarXzPath, destDir string) error {
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
@@ -445,15 +476,13 @@ func extractTarXz(tarXzPath, destDir string) error {
return nil
}
// 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" (ignored for ALAC)
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"`
Codec string `json:"codec"`
}
// ConvertAudioResult represents the result of a single file conversion
type ConvertAudioResult struct {
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
@@ -461,7 +490,6 @@ type ConvertAudioResult struct {
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 {
@@ -481,7 +509,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
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) {
@@ -491,16 +518,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
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
@@ -510,11 +534,9 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
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
@@ -526,9 +548,14 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
result.OutputFile = outputFile
// Extract cover art and lyrics from input file before conversion
var coverArtPath string
var lyrics string
var inputMetadata Metadata
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
}
coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(inputFile)
@@ -540,49 +567,42 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
}
// Build ffmpeg command
inputMetadata.Lyrics = lyrics
args := []string{
"-i", inputFile,
"-y", // Overwrite output
"-y",
}
// 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", "0:a",
"-id3v2_version", "3",
)
// Map video stream if exists (for cover art)
args = append(args, "-map", "0:v?", "-c:v", "copy")
case "m4a":
// Determine codec: ALAC (lossless) or AAC (lossy)
codec := req.Codec
if codec == "" {
codec = "aac" // Default to AAC for backward compatibility
codec = "aac"
}
if codec == "alac" {
// ALAC - Apple Lossless (no bitrate needed)
args = append(args,
"-codec:a", "alac",
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
"-map", "0:a",
)
} else {
// AAC - lossy with bitrate
args = append(args,
"-codec:a", "aac",
"-b:a", req.Bitrate,
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
"-map", "0:a",
)
}
// 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)
@@ -590,7 +610,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
cmd := exec.Command(ffmpegPath, args...)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -599,21 +619,17 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
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 err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
}
if lyrics != "" {
@@ -624,6 +640,10 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
}
}
if coverArtPath != "" {
os.Remove(coverArtPath)
}
result.Success = true
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
@@ -637,7 +657,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
return results, nil
}
// GetAudioInfo returns information about an audio file
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
@@ -645,7 +664,6 @@ type AudioFileInfo struct {
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 {
+1 -3
View File
@@ -7,8 +7,6 @@ import (
"os/exec"
)
// setHideWindow is a no-op on non-Windows platforms
func setHideWindow(cmd *exec.Cmd) {
// No-op on Unix-like systems
}
}
-2
View File
@@ -8,10 +8,8 @@ import (
"syscall"
)
// setHideWindow sets HideWindow attribute for Windows processes
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}
-3
View File
@@ -6,7 +6,6 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// SelectMultipleFiles opens a file dialog to select multiple audio files
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files",
@@ -39,7 +38,6 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
return files, nil
}
// SelectOutputDirectory opens a directory dialog to select output folder
func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory",
@@ -49,4 +47,3 @@ func SelectOutputDirectory(ctx context.Context) (string, error) {
}
return dir, nil
}
+3 -38
View File
@@ -14,7 +14,6 @@ import (
"github.com/go-flac/go-flac"
)
// FileInfo represents information about a file or folder
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
@@ -23,7 +22,6 @@ type FileInfo struct {
Children []FileInfo `json:"children,omitempty"`
}
// AudioMetadata represents metadata read from an audio file
type AudioMetadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
@@ -34,7 +32,6 @@ type AudioMetadata struct {
Year string `json:"year"`
}
// RenamePreview represents a preview of file rename operation
type RenamePreview struct {
OldPath string `json:"old_path"`
OldName string `json:"old_name"`
@@ -44,7 +41,6 @@ type RenamePreview struct {
Metadata AudioMetadata `json:"metadata"`
}
// RenameResult represents the result of a rename operation
type RenameResult struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
@@ -52,7 +48,6 @@ type RenameResult struct {
Error string `json:"error,omitempty"`
}
// ListDirectory lists files and folders in a directory
func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
@@ -73,7 +68,6 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
Size: info.Size(),
}
// If it's a directory, recursively list its contents
if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path)
if err == nil {
@@ -87,13 +81,12 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
return result, nil
}
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files with errors
return nil
}
if info.IsDir() {
@@ -120,7 +113,6 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
return result, nil
}
// ReadAudioMetadata reads metadata from an audio file
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
@@ -140,7 +132,6 @@ func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
}
}
// readFlacMetadata reads metadata from a FLAC file
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -192,7 +183,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// readMp3Metadata reads metadata from an MP3 file
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -207,14 +197,12 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
Year: tag.Year(),
}
// Get Album Artist (TPE2)
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text
}
}
// Get Track Number
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0]
@@ -224,7 +212,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
}
}
// Get Disc Number
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0]
@@ -237,7 +224,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
@@ -248,7 +234,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
}
// Use ffprobe to get metadata in JSON format (both format and stream tags)
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
@@ -257,7 +242,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
filePath,
)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.Output()
@@ -265,7 +249,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
return nil, err
}
// Parse JSON output
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
@@ -281,22 +264,18 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
metadata := &AudioMetadata{}
// Merge tags from format and streams (format tags take priority)
allTags := make(map[string]string)
// First add stream tags
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
// Then add format tags (overwrite stream tags)
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
// Parse tags
for key, value := range allTags {
switch key {
case "title":
@@ -308,7 +287,7 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "track":
// Format might be "4" or "4/12"
trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
@@ -328,7 +307,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// readM4aMetadata reads metadata from an M4A file using ffprobe
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath)
if err != nil {
@@ -337,7 +315,6 @@ func readM4aMetadata(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// GenerateFilename generates a new filename based on metadata and format template
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil {
return ""
@@ -345,38 +322,32 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
result := format
// Extract year (first 4 characters only)
year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
// Replace placeholders
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
// Track number with padding
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else {
result = strings.ReplaceAll(result, "{track}", "")
}
// Disc number
if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else {
result = strings.ReplaceAll(result, "{disc}", "")
}
// Clean up multiple spaces and trim
result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ")
// Remove leading/trailing separators
result = strings.Trim(result, " -._")
if result == "" {
@@ -386,9 +357,8 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
return result + ext
}
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
func sanitizeFilenameForRename(name string) string {
// Remove characters that are invalid in filenames
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name
for _, char := range invalid {
@@ -397,7 +367,6 @@ func sanitizeFilenameForRename(name string) string {
return strings.TrimSpace(result)
}
// PreviewRename generates a preview of rename operations
func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview
@@ -434,7 +403,6 @@ func PreviewRename(files []string, format string) []RenamePreview {
return previews
}
// GetFileSizes returns file sizes for a list of file paths
func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64)
for _, filePath := range files {
@@ -446,7 +414,6 @@ func GetFileSizes(files []string) map[string]int64 {
return result
}
// RenameFiles renames files based on their metadata
func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult
@@ -476,7 +443,6 @@ func RenameFiles(files []string, format string) []RenameResult {
newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath
// Check if new path already exists (and is different from old path)
if newPath != filePath {
if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists"
@@ -486,7 +452,6 @@ func RenameFiles(files []string, format string) []RenameResult {
}
}
// Rename the file
if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error()
result.Success = false
+13 -48
View File
@@ -9,15 +9,13 @@ import (
"unicode/utf8"
)
// BuildExpectedFilename builds the expected filename based on track metadata and settings
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
@@ -25,7 +23,6 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
@@ -34,34 +31,31 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators like ". " or " - " or ". "
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
@@ -70,109 +64,81 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
return filename + ".flac"
}
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(name string) string {
// Replace forward slash with space (more natural than underscore)
sanitized := strings.ReplaceAll(name, "/", " ")
// Remove other invalid filesystem characters (replace with space)
re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ")
// Remove control characters (0x00-0x1F, 0x7F)
var result strings.Builder
for _, r := range sanitized {
// Keep printable characters and valid Unicode characters
// Remove control characters, but keep spaces, tabs, newlines for now
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
// Remove emoji and other symbols that might cause issues
// Keep letters, numbers, and common punctuation
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
// Remove emoji ranges (most emoji are in these ranges)
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
continue
}
result.WriteRune(r)
}
sanitized = result.String()
sanitized = strings.TrimSpace(sanitized)
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
sanitized = strings.Trim(sanitized, ". ")
// Normalize consecutive spaces to single space
re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ")
// Normalize consecutive underscores to single underscore
re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_")
// Remove leading/trailing underscores and spaces
sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" {
return "Unknown"
}
// Ensure the result is valid UTF-8
if !utf8.ValidString(sanitized) {
// If invalid UTF-8, try to fix it
sanitized = strings.ToValidUTF8(sanitized, "_")
}
return sanitized
}
// NormalizePath only normalizes path separators without modifying folder names
// Use this for user-provided paths that already exist on the filesystem
func NormalizePath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
}
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
// Use this only for NEW folders being created (artist names, album names, etc.)
func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
// Detect separator
sep := string(filepath.Separator)
// Split path into components
parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts {
// Keep drive letter intact on Windows (e.g., "C:")
if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
if i == 0 && part == "" {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Sanitize each folder name (but don't replace / or \ since we already normalized)
sanitized := sanitizeFolderName(part)
if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized)
@@ -182,8 +148,7 @@ func SanitizeFolderPath(folderPath string) string {
return strings.Join(sanitizedParts, sep)
}
// sanitizeFolderName removes invalid characters from a single folder name
func sanitizeFolderName(name string) string {
// Use the same sanitization as filename
return sanitizeFilename(name)
}
+2 -4
View File
@@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error {
switch runtime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin": // macOS
case "darwin":
cmd = exec.Command("open", path)
case "linux":
cmd = exec.Command("xdg-open", path)
@@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error {
}
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
// If defaultPath is empty, use default music path
if defaultPath == "" {
defaultPath = GetDefaultMusicPath()
}
@@ -41,7 +41,6 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
return "", err
}
// If user cancelled, selectedPath will be empty
if selectedPath == "" {
return "", nil
}
@@ -69,7 +68,6 @@ func SelectFileDialog(ctx context.Context) (string, error) {
return "", err
}
// If user cancelled, selectedFile will be empty
if selectedFile == "" {
return "", nil
}
+77 -57
View File
@@ -14,7 +14,6 @@ import (
"time"
)
// LRCLibResponse represents the LRCLIB API response
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -27,21 +26,18 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
}
// LyricsLine represents a single line of lyrics
type LyricsLine struct {
StartTimeMs string `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs string `json:"endTimeMs"`
}
// LyricsResponse represents the API response
type LyricsResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []LyricsLine `json:"lines"`
}
// LyricsDownloadRequest represents a request to download lyrics
type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
@@ -57,7 +53,6 @@ type LyricsDownloadRequest struct {
DiscNumber int `json:"disc_number"`
}
// LyricsDownloadResponse represents the response from lyrics download
type LyricsDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
@@ -66,27 +61,28 @@ type LyricsDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
}
// LyricsClient handles lyrics fetching
type LyricsClient struct {
httpClient *http.Client
}
// NewLyricsClient creates a new lyrics client
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
// Try LRCLIB API
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
url.QueryEscape(artistName),
url.QueryEscape(trackName))
if duration > 0 {
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
}
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
@@ -107,11 +103,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*L
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
}
// Convert LRCLIB response to our LyricsResponse format
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
}
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
resp := &LyricsResponse{
Error: false,
@@ -119,7 +113,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
Lines: []LyricsLine{},
}
// Prefer synced lyrics, fall back to plain
lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics
@@ -131,7 +124,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
return resp
}
// Parse synced lyrics format [mm:ss.xx] text
lines := strings.Split(lyricsText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
@@ -139,14 +131,12 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
continue
}
// Check if line has timestamp [mm:ss.xx]
if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]")
if closeBracket > 0 {
timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:])
// Convert [mm:ss.xx] to milliseconds
ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
@@ -156,9 +146,8 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
}
}
// Plain lyrics line (no timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "0",
StartTimeMs: "",
Words: line,
})
}
@@ -166,10 +155,9 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
return resp
}
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
func lrcTimestampToMs(timestamp string) int64 {
var minutes, seconds, centiseconds int64
// Try parsing mm:ss.xx format
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
@@ -177,7 +165,6 @@ func lrcTimestampToMs(timestamp string) int64 {
return 0
}
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
@@ -207,7 +194,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return nil, fmt.Errorf("no results found")
}
// Find best match - prefer one with synced lyrics
var best *LRCLibResponse
for i := range results {
if results[i].SyncedLyrics != "" {
@@ -226,41 +212,37 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return c.convertLRCLibToLyricsResponse(best), nil
}
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
func simplifyTrackName(name string) string {
// Remove content in parentheses
if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
// Remove content after " - " (like "From the Motion Picture")
if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
return name
}
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
// 1. Try LRCLIB exact match
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil
}
fmt.Printf(" LRCLIB exact: %v\n", err)
// 2. Try LRCLIB search
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search", nil
}
fmt.Printf(" LRCLIB search: %v\n", err)
// 3. Try with simplified track name (remove parentheses, subtitles)
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil
}
@@ -274,31 +256,31 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return nil, "", fmt.Errorf("lyrics not found in any source")
}
// ConvertToLRC converts lyrics response to LRC format
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder
// Add metadata
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
sb.WriteString("[by:SpotiFlac]\n")
sb.WriteString("\n")
// Add lyrics lines
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
// Convert milliseconds to LRC timestamp format [mm:ss.xx]
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
if line.StartTimeMs == "" {
sb.WriteString(fmt.Sprintf("%s\n", line.Words))
} else {
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
}
}
return sb.String()
}
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
func msToLRCTimestamp(msStr string) string {
var ms int64
fmt.Sscanf(msStr, "%d", &ms)
@@ -311,14 +293,12 @@ func msToLRCTimestamp(msStr string) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
@@ -326,7 +306,6 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
@@ -335,34 +314,31 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
@@ -371,7 +347,47 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
return filename + ".lrc"
}
// DownloadLyrics downloads lyrics for a single track
func findAudioFileForLyrics(dir, trackName, artistName string) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"}
patterns := []string{
fmt.Sprintf("%s - %s", safeTitle, safeArtist),
fmt.Sprintf("%s - %s", safeArtist, safeTitle),
safeTitle,
}
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
for _, pattern := range patterns {
if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) {
ext := strings.ToLower(filepath.Ext(filename))
for _, audioExt := range audioExts {
if ext == strings.ToLower(audioExt) {
return filepath.Join(dir, filename)
}
}
}
}
}
return ""
}
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
if req.SpotifyID == "" {
return &LyricsDownloadResponse{
@@ -380,7 +396,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, fmt.Errorf("spotify ID is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
@@ -395,15 +410,13 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
filenameFormat = "title-artist"
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &LyricsDownloadResponse{
Success: true,
@@ -413,8 +426,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, nil
}
// Fetch lyrics from LRCLIB
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
audioDuration := 0
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
if audioFile != "" {
duration, err := GetAudioDuration(audioFile)
if err == nil && duration > 0 {
audioDuration = int(duration)
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
}
}
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
if err != nil {
return &LyricsDownloadResponse{
Success: false,
@@ -422,10 +444,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err
}
// Convert to LRC format
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
// Write LRC file
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
return &LyricsDownloadResponse{
Success: false,
+480 -203
View File
@@ -1,13 +1,13 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
"sync"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
@@ -20,12 +20,15 @@ type Metadata struct {
Artist string
Album string
AlbumArtist string
Date string // Recorded date (full date YYYY-MM-DD)
ReleaseDate string // Release date (full date) - kept for compatibility
Date string
ReleaseDate string
TrackNumber int
TotalTracks int // Total tracks in album
TotalTracks int
DiscNumber int
ISRC string
TotalDiscs int
URL string
Copyright string
Publisher string
Lyrics string
Description string
}
@@ -70,15 +73,21 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
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)
}
// Lyrics is added last to keep it at the bottom
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced
_ = cmt.Add("LYRICS", metadata.Lyrics)
}
cmtBlock := cmt.Marshal()
@@ -135,20 +144,17 @@ func fileExists(path string) bool {
return err == nil
}
// extractYear extracts the year from a release date string
// Handles formats: "YYYY-MM-DD", "YYYY-MM", "YYYY"
func extractYear(releaseDate string) string {
if releaseDate == "" {
return ""
}
// Try to extract year (first 4 digits)
if len(releaseDate) >= 4 {
return releaseDate[:4]
}
return releaseDate
}
// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata
func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" {
return nil
@@ -171,10 +177,8 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
}
}
// Create new comment block, preserving existing comments
cmt := flacvorbis.New()
// Copy existing comments except LYRICS
if existingCmt != nil {
for _, comment := range existingCmt.Comments {
parts := strings.SplitN(comment, "=", 2)
@@ -187,7 +191,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
}
}
// Add lyrics
_ = cmt.Add("LYRICS", lyrics)
cmtBlock := cmt.Marshal()
@@ -204,82 +207,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
return nil
}
// ReadISRCFromFile reads ISRC metadata from a FLAC file
func ReadISRCFromFile(filepath string) (string, error) {
if !fileExists(filepath) {
return "", fmt.Errorf("file does not exist")
}
f, err := flac.ParseFile(filepath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find VorbisComment block
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
// Get ISRC field
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
if err == nil && len(isrcValues) > 0 {
return isrcValues[0], nil
}
}
}
return "", nil // No ISRC found
}
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
if targetISRC == "" {
return "", false
}
// Read all .flac files in directory
entries, err := os.ReadDir(outputDir)
if err != nil {
return "", false
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// Check only .flac files
filename := entry.Name()
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
continue
}
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
// Read ISRC from file (this will fail for corrupted files)
isrc, err := ReadISRCFromFile(filepath)
if err != nil {
// File is corrupted or unreadable, delete it
fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
if removeErr := os.Remove(filepath); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
}
continue
}
// Compare ISRC (case-insensitive)
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
return filepath, true
}
}
return "", false
}
// ExtractCoverArt extracts cover art from an audio file and saves it to a temporary file
func ExtractCoverArt(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -293,7 +220,6 @@ func ExtractCoverArt(filePath string) (string, error) {
}
}
// extractCoverFromMp3 extracts cover art from MP3 file
func extractCoverFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -311,7 +237,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
return "", fmt.Errorf("invalid picture frame")
}
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
@@ -326,7 +251,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
return tmpFile.Name(), nil
}
// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -343,7 +267,6 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
continue
}
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
@@ -361,12 +284,9 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
return "", fmt.Errorf("no cover art found")
}
// For M4A, try to extract using ffmpeg or return empty
// M4A cover art should be preserved by ffmpeg during conversion
return "", nil
}
// ExtractLyrics extracts lyrics from an audio file
func ExtractLyrics(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -376,14 +296,13 @@ func ExtractLyrics(filePath string) (string, error) {
case ".flac":
return extractLyricsFromFlac(filePath)
case ".m4a":
// M4A lyrics extraction would need different approach
return "", nil
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
// extractLyricsFromMp3 extracts lyrics from MP3 file
func extractLyricsFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -412,7 +331,6 @@ func extractLyricsFromMp3(filePath string) (string, error) {
return uslt.Lyrics, nil
}
// extractLyricsFromFlac extracts lyrics from FLAC file
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -426,7 +344,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
continue
}
// Search through comments for lyrics
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
@@ -445,7 +362,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
return "", nil
}
// EmbedCoverArtOnly embeds cover art into an audio file
func EmbedCoverArtOnly(filePath string, coverPath string) error {
if coverPath == "" || !fileExists(coverPath) {
return nil
@@ -457,16 +373,13 @@ func EmbedCoverArtOnly(filePath string, coverPath string) error {
case ".mp3":
return embedCoverToMp3(filePath, coverPath)
case ".m4a":
// M4A cover art should be handled by ffmpeg during conversion
// If not, we can try to embed using atomicparsley or similar tool
// For now, return nil as ffmpeg should handle it
return nil
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
// embedCoverToMp3 embeds cover art into MP3 file
func embedCoverToMp3(filePath string, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -474,16 +387,13 @@ func embedCoverToMp3(filePath string, coverPath string) error {
}
defer tag.Close()
// Remove existing cover art
tag.DeleteFrames(tag.CommonID("Attached picture"))
// Read cover art
artwork, err := os.ReadFile(coverPath)
if err != nil {
return fmt.Errorf("failed to read cover art: %w", err)
}
// Add new cover art
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
@@ -500,27 +410,30 @@ func embedCoverToMp3(filePath string, coverPath string) error {
return nil
}
// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame
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()
// Remove existing USLT frames
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
// Add new USLT frame with lyrics
// Use UTF-8 encoding for better compatibility with AIMP and other players
usltFrame := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding
Encoding: id3v2.EncodingUTF8,
Language: "eng",
ContentDescriptor: "", // Empty descriptor for better compatibility
ContentDescriptor: "",
Lyrics: lyrics,
}
tag.AddUnsynchronisedLyricsFrame(usltFrame)
@@ -532,10 +445,15 @@ func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
return nil
}
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
func embedLyricsToM4A(filepath string, lyrics string) error {
// Use ffmpeg to embed lyrics into M4A file
// M4A uses iTunes metadata format with atom '©lyr' for lyrics
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)
@@ -545,18 +463,14 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
// Create temporary output file with proper extension so ffmpeg can detect format
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
defer func() {
// Only remove if file still exists (rename might have failed)
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
// Use ffmpeg to copy file and add lyrics metadata
// For M4A, we need to use the correct metadata tag format and specify output format
// Use -f ipod for M4A format (iPod format is compatible with M4A)
cmd := exec.Command(ffmpegPath,
"-i", filepath,
"-map", "0",
@@ -564,12 +478,11 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
"-metadata", "lyrics-eng="+lyrics,
"-metadata", "lyrics="+lyrics,
"-codec", "copy",
"-f", "ipod", // Explicitly specify M4A/iPod format
"-y", // Overwrite
"-f", "ipod",
"-y",
tmpOutputFile,
)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
@@ -578,7 +491,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
}
// Replace original file with new file
if err := os.Rename(tmpOutputFile, filepath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
@@ -587,7 +499,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
return nil
}
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
if lyrics == "" {
return nil
@@ -606,85 +517,451 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
}
}
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct {
ISRC string `json:"isrc"`
Exists bool `json:"exists"`
FilePath string `json:"file_path,omitempty"`
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
}
func GetAudioDuration(filepath string) (float64, error) {
ext := strings.ToLower(pathfilepath.Ext(filepath))
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
func CheckFilesExistParallel(outputDir string, tracks []struct {
ISRC string
TrackName string
ArtistName string
}) []FileExistenceResult {
results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIndex := buildISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
go func(idx int, t struct {
ISRC string
TrackName string
ArtistName string
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
}
if t.ISRC != "" {
if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists {
result.Exists = true
result.FilePath = filePath
}
}
results[idx] = result
}(i, track)
if ext == ".flac" {
duration, err := getFlacDuration(filepath)
if err == nil && duration > 0 {
return duration, nil
}
}
wg.Wait()
return results
return getDurationWithFFprobe(filepath)
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) map[string]string {
index := make(map[string]string)
func getFlacDuration(filepath string) (float64, error) {
f, err := flac.ParseFile(filepath)
if err != nil {
return 0, err
}
// Walk directory recursively - only check .flac files for SpotiFLAC
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
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
}
}
}
}
ext := strings.ToLower(pathfilepath.Ext(path))
if ext != ".flac" {
return nil
}
// Read ISRC from file
isrc, err := ReadISRCFromFile(path)
if err != nil || isrc == "" {
return nil
}
// Store in index (uppercase for case-insensitive matching)
index[strings.ToUpper(isrc)] = path
return nil
})
return index
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, "[") {
if strings.Index(trimmedLine, ":") > 0 {
validLines = append(validLines, line)
continue
}
closeBracket := strings.Index(trimmedLine, "]")
if closeBracket > 0 {
timestampStr := trimmedLine[1:closeBracket]
ms := parseLRCTimestamp(timestampStr)
if ms >= 0 && 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)
}
} 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, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return -1
}
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
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 {
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 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)
}
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
}
+14 -54
View File
@@ -7,7 +7,6 @@ import (
"time"
)
// DownloadStatus represents the status of a download item
type DownloadStatus string
const (
@@ -18,7 +17,6 @@ const (
StatusSkipped DownloadStatus = "skipped"
)
// DownloadItem represents a single item in the download queue
type DownloadItem struct {
ID string `json:"id"`
TrackName string `json:"track_name"`
@@ -26,16 +24,15 @@ type DownloadItem struct {
AlbumName string `json:"album_name"`
ISRC string `json:"isrc"`
Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"` // MB downloaded
TotalSize float64 `json:"total_size"` // MB total (if known)
Speed float64 `json:"speed"` // MB/s
StartTime int64 `json:"start_time"` // Unix timestamp
EndTime int64 `json:"end_time"` // Unix timestamp
ErrorMessage string `json:"error_message"` // If failed
FilePath string `json:"file_path"` // Final file path
Progress float64 `json:"progress"`
TotalSize float64 `json:"total_size"`
Speed float64 `json:"speed"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
ErrorMessage string `json:"error_message"`
FilePath string `json:"file_path"`
}
// Global progress tracker
var (
currentProgress float64
currentProgressLock sync.RWMutex
@@ -44,7 +41,6 @@ var (
currentSpeed float64
speedLock sync.RWMutex
// Download queue tracking
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
@@ -55,27 +51,24 @@ var (
sessionStartLock sync.RWMutex
)
// ProgressInfo represents download progress information
type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"`
}
// DownloadQueueInfo represents the complete download queue state
type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"`
CurrentSpeed float64 `json:"current_speed"` // MB/s
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
CurrentSpeed float64 `json:"current_speed"`
TotalDownloaded float64 `json:"total_downloaded"`
SessionStartTime int64 `json:"session_start_time"`
QueuedCount int `json:"queued_count"`
CompletedCount int `json:"completed_count"`
FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"`
}
// GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
downloading := isDownloading
@@ -96,34 +89,30 @@ func GetDownloadProgress() ProgressInfo {
}
}
// SetDownloadSpeed updates the current download speed
func SetDownloadSpeed(mbps float64) {
speedLock.Lock()
currentSpeed = mbps
speedLock.Unlock()
}
// SetDownloadProgress updates the current download progress
func SetDownloadProgress(mbDownloaded float64) {
currentProgressLock.Lock()
currentProgress = mbDownloaded
currentProgressLock.Unlock()
}
// SetDownloading sets the downloading state
func SetDownloading(downloading bool) {
downloadingLock.Lock()
isDownloading = downloading
downloadingLock.Unlock()
if !downloading {
// Reset progress when download completes
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
}
// ProgressWriter wraps an io.Writer and reports download progress
type ProgressWriter struct {
writer io.Writer
total int64
@@ -131,7 +120,7 @@ type ProgressWriter struct {
startTime int64
lastTime int64
lastBytes int64
itemID string // Track which download item this belongs to
itemID string
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -147,7 +136,6 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
}
}
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer)
pw.itemID = itemID
@@ -162,13 +150,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.total += int64(n)
// Report progress every 256KB for smoother updates
if pw.total-pw.lastPrinted >= 256*1024 {
mbDownloaded := float64(pw.total) / (1024 * 1024)
// Calculate speed (MB/s)
now := getCurrentTimeMillis()
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
timeDiff := float64(now-pw.lastTime) / 1000.0
bytesDiff := float64(pw.total - pw.lastBytes)
var speedMBps float64
@@ -180,10 +166,8 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
}
// Update global progress
SetDownloadProgress(mbDownloaded)
// Update individual item progress if we have an item ID
if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
}
@@ -200,9 +184,6 @@ func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
// Queue management functions
// AddToQueue adds a new item to the download queue
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -223,7 +204,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueue = append(downloadQueue, item)
// Initialize session start time if this is the first item
sessionStartLock.Lock()
if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix()
@@ -231,7 +211,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
sessionStartLock.Unlock()
}
// StartDownloadItem marks an item as currently downloading
func StartDownloadItem(id string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -250,7 +229,6 @@ func StartDownloadItem(id string) {
currentItemLock.Unlock()
}
// UpdateItemProgress updates the progress of the current download item
func UpdateItemProgress(id string, progress, speed float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -264,14 +242,12 @@ func UpdateItemProgress(id string, progress, speed float64) {
}
}
// GetCurrentItemID returns the ID of the currently downloading item
func GetCurrentItemID() string {
currentItemLock.RLock()
defer currentItemLock.RUnlock()
return currentItemID
}
// CompleteDownloadItem marks an item as completed
func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -284,7 +260,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueue[i].Progress = finalSize
downloadQueue[i].TotalSize = finalSize
// Add to total downloaded
totalDownloadedLock.Lock()
totalDownloaded += finalSize
totalDownloadedLock.Unlock()
@@ -293,7 +268,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
}
}
// FailDownloadItem marks an item as failed
func FailDownloadItem(id, errorMsg string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -308,7 +282,6 @@ func FailDownloadItem(id, errorMsg string) {
}
}
// SkipDownloadItem marks an item as skipped (already exists)
func SkipDownloadItem(id, filePath string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -323,9 +296,8 @@ func SkipDownloadItem(id, filePath string) {
}
}
// GetDownloadQueue returns the complete download queue state
func GetDownloadQueue() DownloadQueueInfo {
// Auto-reset session if all downloads are complete
ResetSessionIfComplete()
downloadQueueLock.RLock()
@@ -347,7 +319,6 @@ func GetDownloadQueue() DownloadQueueInfo {
sessionStart := sessionStartTime
sessionStartLock.RUnlock()
// Count statuses
var queued, completed, failed, skipped int
for _, item := range downloadQueue {
switch item.Status {
@@ -362,7 +333,6 @@ func GetDownloadQueue() DownloadQueueInfo {
}
}
// Create a copy of the queue
queueCopy := make([]DownloadItem, len(downloadQueue))
copy(queueCopy, downloadQueue)
@@ -379,12 +349,10 @@ func GetDownloadQueue() DownloadQueueInfo {
}
}
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
func ClearDownloadQueue() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
// Keep only queued and downloading items
newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
@@ -394,7 +362,6 @@ func ClearDownloadQueue() {
downloadQueue = newQueue
}
// ClearAllDownloads clears the entire queue and resets session stats
func ClearAllDownloads() {
downloadQueueLock.Lock()
downloadQueue = []DownloadItem{}
@@ -412,13 +379,10 @@ func ClearAllDownloads() {
currentItemID = ""
currentItemLock.Unlock()
// Reset current progress and speed
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
// CancelAllQueuedItems marks all queued items as skipped (cancelled)
// This is called when user stops a download or when batch download completes
func CancelAllQueuedItems() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -432,8 +396,6 @@ func CancelAllQueuedItems() {
}
}
// ResetSessionIfComplete resets session stats if no active or queued downloads
// Note: Does NOT clear the queue - items remain visible for history
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
@@ -445,8 +407,6 @@ func ResetSessionIfComplete() {
}
downloadQueueLock.RUnlock()
// If no active or queued items, reset session stats
// But keep the queue items for history visibility
if !hasActiveOrQueued {
sessionStartLock.Lock()
sessionStartTime = 0
+26 -47
View File
@@ -78,7 +78,7 @@ func NewQobuzDownloader() *QobuzDownloader {
}
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
@@ -93,7 +93,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
var searchResp QobuzSearchResponse
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -104,7 +104,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
if err := json.Unmarshal(body, &searchResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -120,20 +120,17 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
// Map quality to Qobuz quality code
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
qualityCode := quality // Use the provided quality parameter
qualityCode := quality
if qualityCode == "" {
qualityCode = "6" // Default to FLAC 16-bit if not specified
qualityCode = "6"
}
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
@@ -154,7 +151,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
resp.Body.Close()
}
// Fallback to secondary API
fmt.Println("Primary API failed, trying fallback...")
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
@@ -184,7 +180,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -202,10 +198,9 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
// Use a separate client with a longer timeout. The default client's 60s limit
// causes downloads to fail on slow connections or for large Hi-Res files.
downloadClient := &http.Client{
Timeout: 5 * time.Minute, // 5 minutes for large files
Timeout: 5 * time.Minute,
}
resp, err := downloadClient.Get(url)
@@ -226,14 +221,13 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
@@ -266,19 +260,16 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Determine track number to use
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
// Check if format is a template (contains {})
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
@@ -287,34 +278,31 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
@@ -323,22 +311,20 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
// Create output directory if it doesn't exist
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(isrc)
track, err := q.SearchByISRC(deezerISRC)
if err != nil {
return "", err
}
// All metadata from Spotify - no fallback to Qobuz
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
@@ -362,7 +348,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
return "", fmt.Errorf("received empty download URL")
}
// Show partial URL for security
urlPreview := downloadURL
if len(downloadURL) > 60 {
urlPreview = downloadURL[:60] + "..."
@@ -374,13 +359,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings (use Spotify track number)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
@@ -397,7 +375,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
if spotifyCoverURL != "" {
coverPath = filepath + ".cover.jpg"
coverClient := NewCoverClient()
@@ -412,23 +390,24 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Println("Embedding metadata and cover art...")
// Determine track number to embed - ALL from Spotify
trackNumberToEmbed := spotifyTrackNumber
if position > 0 && !useAlbumTrackNumber {
trackNumberToEmbed = position // Use playlist position
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
// ALL metadata from Spotify
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
ISRC: isrc, // ISRC from Spotify (passed as parameter)
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
+21 -35
View File
@@ -5,7 +5,6 @@ import (
"unicode"
)
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
@@ -17,20 +16,19 @@ var hiraganaToRomaji = map[rune]string{
'や': "ya", 'ゆ': "yu", 'よ': "yo",
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
'わ': "wa", 'を': "wo", 'ん': "n",
// Dakuten (voiced)
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// Handakuten (semi-voiced)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "", // Double consonant marker
'っ': "",
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
@@ -42,23 +40,22 @@ var katakanaToRomaji = map[rune]string{
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
// Dakuten (voiced)
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// Handakuten (semi-voiced)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "", // Double consonant marker
'ッ': "",
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
// Extended katakana
'ー': "", // Long vowel mark
'ー': "",
'ヴ': "vu",
}
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
@@ -85,13 +82,12 @@ var combinationKatakana = map[string]string{
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
// Extended combinations
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
}
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) {
@@ -110,12 +106,10 @@ func isKatakana(r rune) bool {
}
func isKanji(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
return (r >= 0x4E00 && r <= 0x9FFF) ||
(r >= 0x3400 && r <= 0x4DBF)
}
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) {
return text
@@ -126,7 +120,7 @@ func JapaneseToRomaji(text string) string {
i := 0
for i < len(runes) {
// Check for っ/ッ (double consonant)
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
nextRomaji := ""
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
@@ -135,13 +129,12 @@ func JapaneseToRomaji(text string) string {
nextRomaji = romaji
}
if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0]) // Double the first consonant
result.WriteByte(nextRomaji[0])
}
i++
continue
}
// Check for two-character combinations
if i < len(runes)-1 {
combo := string(runes[i : i+2])
if romaji, ok := combinationHiragana[combo]; ok {
@@ -156,17 +149,16 @@ func JapaneseToRomaji(text string) string {
}
}
// Single character conversion
r := runes[i]
if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji)
} else if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji)
} else if isKanji(r) {
// Keep kanji as-is (would need dictionary for proper conversion)
result.WriteRune(r)
} else {
// Keep other characters (punctuation, spaces, etc.)
result.WriteRune(r)
}
i++
@@ -175,21 +167,17 @@ func JapaneseToRomaji(text string) string {
return result.String()
}
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName)
// Clean up the query - remove special characters that might interfere with search
trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji)
return strings.TrimSpace(artistClean + " " + trackClean)
}
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string {
var result strings.Builder
for _, r := range s {
@@ -202,21 +190,19 @@ func cleanSearchQuery(s string) string {
return strings.TrimSpace(result.String())
}
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func cleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
// Keep only ASCII letters, numbers, spaces, and basic punctuation
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r)
} else if r == ',' || r == '.' {
// Convert punctuation to space
result.WriteRune(' ')
}
}
// Clean up multiple spaces
cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned)
}
+152 -30
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"time"
)
@@ -22,7 +23,6 @@ type SongLinkURLs struct {
AmazonURL string `json:"amazon_url"`
}
// TrackAvailability represents the availability of a track on different platforms
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
@@ -43,14 +43,13 @@ func NewSongLinkClient() *SongLinkClient {
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
@@ -61,7 +60,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
@@ -72,7 +70,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -86,7 +83,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
fmt.Println("Getting streaming URLs from song.link...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -95,7 +91,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return nil, fmt.Errorf("failed to get URLs: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
@@ -124,7 +119,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
URL string `json:"url"`
} `json:"linksByPlatform"`
}
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -135,7 +130,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -145,23 +140,20 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
urls := &SongLinkURLs{}
// Extract Tidal URL
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n")
}
// Extract Amazon URL
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n")
}
}
// Check if at least one URL was found
if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
@@ -169,16 +161,14 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return urls, nil
}
// CheckTrackAvailability checks the availability of a track on different platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
@@ -189,7 +179,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
@@ -200,7 +189,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -214,7 +202,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -223,7 +210,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to check availability: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
@@ -252,7 +238,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
URL string `json:"url"`
} `json:"linksByPlatform"`
}
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -263,7 +249,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -275,33 +261,33 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Qobuz using ISRC (song.link doesn't support Qobuz)
if isrc != "" {
qobuzAvailable := checkQobuzAvailability(isrc)
availability.Qobuz = qobuzAvailable
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL
deezerISRC, err := GetDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable
}
}
return availability, nil
}
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
@@ -326,3 +312,139 @@ func checkQobuzAvailability(isrc string) bool {
return searchResp.Tracks.Total > 0
}
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL from song.link...")
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func GetDeezerISRC(deezerURL string) (string, error) {
var trackID string
if strings.Contains(deezerURL, "/track/") {
parts := strings.Split(deezerURL, "/track/")
if len(parts) > 1 {
trackID = strings.Split(parts[1], "?")[0]
trackID = strings.TrimSpace(trackID)
}
}
if trackID == "" {
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
}
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return "", fmt.Errorf("failed to call Deezer API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
}
var deezerTrack struct {
ID int64 `json:"id"`
ISRC string `json:"isrc"`
Title string `json:"title"`
}
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
}
if deezerTrack.ISRC == "" {
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
}
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
return deezerTrack.ISRC, nil
}
+3 -17
View File
@@ -8,7 +8,6 @@ import (
"github.com/mewkiz/flac"
)
// SpectrumData contains frequency spectrum information
type SpectrumData struct {
TimeSlices []TimeSlice `json:"time_slices"`
SampleRate int `json:"sample_rate"`
@@ -17,15 +16,13 @@ type SpectrumData struct {
MaxFreq float64 `json:"max_freq"`
}
// TimeSlice represents spectrum data at a point in time
type TimeSlice struct {
Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"`
}
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
// Open FLAC file
stream, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
@@ -36,7 +33,6 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
// Read audio samples
samples, err := readSamples(stream, channels)
if err != nil {
return nil, fmt.Errorf("failed to read samples: %w", err)
@@ -46,28 +42,23 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
return nil, fmt.Errorf("no audio samples found")
}
// Calculate spectrum
return calculateSpectrum(samples, sampleRate), nil
}
// readSamples reads and decodes audio samples from FLAC stream
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
var allSamples []float64
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
maxSamples := 10 * 1024 * 1024
// Decode frames
for {
frame, err := stream.ParseNext()
if err != nil {
// End of stream
break
}
// Convert samples to float64 and mix channels to mono
for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64
// Mix all channels to mono by averaging
for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i])
}
@@ -75,7 +66,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
allSamples = append(allSamples, sample)
// Limit sample count
if len(allSamples) >= maxSamples {
return allSamples, nil
}
@@ -85,7 +75,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
return allSamples, nil
}
// calculateSpectrum performs FFT analysis on audio samples
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
fftSize := 8192
numTimeSlices := 300
@@ -140,7 +129,6 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
}
}
// applyHannWindow applies Hann window to reduce spectral leakage
func applyHannWindow(samples []float64) []float64 {
n := len(samples)
windowed := make([]float64, n)
@@ -153,7 +141,6 @@ func applyHannWindow(samples []float64) []float64 {
return windowed
}
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
func fft(samples []float64) []complex128 {
n := len(samples)
@@ -165,7 +152,6 @@ func fft(samples []float64) []complex128 {
return fftRecursive(x)
}
// fftRecursive performs recursive FFT
func fftRecursive(x []complex128) []complex128 {
n := len(x)
File diff suppressed because it is too large Load Diff
+69 -460
View File
File diff suppressed because it is too large Load Diff