diff --git a/README.md b/README.md index ca644f3..c7a1580 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
-Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required. +Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required. ![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=) ![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white) diff --git a/app.go b/app.go index 67a6674..aaec347 100644 --- a/app.go +++ b/app.go @@ -37,25 +37,27 @@ type SpotifyMetadataRequest struct { // DownloadRequest represents the request structure for downloading tracks type DownloadRequest struct { - ISRC string `json:"isrc"` - Service string `json:"service"` - Query string `json:"query,omitempty"` - TrackName string `json:"track_name,omitempty"` - ArtistName string `json:"artist_name,omitempty"` - AlbumName string `json:"album_name,omitempty"` - ApiURL string `json:"api_url,omitempty"` - OutputDir string `json:"output_dir,omitempty"` - AudioFormat string `json:"audio_format,omitempty"` - FilenameFormat string `json:"filename_format,omitempty"` - TrackNumber bool `json:"track_number,omitempty"` - Position int `json:"position,omitempty"` // Position in playlist/album (1-based) - UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position - SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID - EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file + ISRC string `json:"isrc"` + Service string `json:"service"` + Query string `json:"query,omitempty"` + TrackName string `json:"track_name,omitempty"` + ArtistName string `json:"artist_name,omitempty"` + AlbumName string `json:"album_name,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + ApiURL string `json:"api_url,omitempty"` + OutputDir string `json:"output_dir,omitempty"` + AudioFormat string `json:"audio_format,omitempty"` + FilenameFormat string `json:"filename_format,omitempty"` + TrackNumber bool `json:"track_number,omitempty"` + Position int `json:"position,omitempty"` // Position in playlist/album (1-based) + UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position + SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID + EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` // Whether to embed max quality cover art - ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call - Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching - ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking + ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call + Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching + ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking } // DownloadResponse represents the response structure for download operations @@ -128,7 +130,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } if req.Service == "" { - req.Service = "deezer" + req.Service = "tidal" } if req.OutputDir == "" { @@ -225,7 +227,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { downloader := backend.NewTidalDownloader("") if req.ServiceURL != "" { // Use provided URL directly with fallback to multiple APIs - filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -234,13 +236,13 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { }, fmt.Errorf("spotify ID is required for Tidal") } // Use ISRC matching for search fallback - filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber, req.Duration) + filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration) } } else { downloader := backend.NewTidalDownloader(req.ApiURL) if req.ServiceURL != "" { // Use provided URL directly with specific API - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -249,7 +251,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { }, fmt.Errorf("spotify ID is required for Tidal") } // Use ISRC matching for search fallback - filename, err = downloader.DownloadWithISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber, req.Duration) + filename, err = downloader.DownloadWithISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration) } } @@ -260,22 +262,13 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if quality == "" { quality = "6" } - filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber) - default: // deezer - downloader := backend.NewDeezerDownloader() - if req.ServiceURL != "" { - // Use provided URL directly - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) - } else { - if req.SpotifyID == "" { - return DownloadResponse{ - Success: false, - Error: "Spotify ID is required for Deezer", - }, fmt.Errorf("spotify ID is required for Deezer") - } - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) - } + default: + return DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Unknown service: %s", req.Service), + }, fmt.Errorf("unknown service: %s", req.Service) } if err != nil { @@ -314,9 +307,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { fmt.Printf("Track: %s\n", trackName) fmt.Printf("Artist: %s\n", artistName) fmt.Println("Searching all sources...") - + lyricsClient := backend.NewLyricsClient() - + // Try all sources with fallbacks lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName) if err != nil { @@ -324,29 +317,29 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") return } - + if lyricsResp == nil || len(lyricsResp.Lines) == 0 { fmt.Println("No lyrics content found") fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") return } - + fmt.Printf("Lyrics found from: %s\n", source) fmt.Printf("Sync type: %s\n", lyricsResp.SyncType) fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines)) - + lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName) if lyrics == "" { fmt.Println("No lyrics content to embed") fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") return } - + // Show full lyrics in console for debugging fmt.Printf("\n--- Full LRC Content ---\n") fmt.Println(lyrics) fmt.Printf("--- End LRC Content ---\n\n") - + fmt.Printf("Embedding into: %s\n", filePath) if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil { fmt.Printf("Failed to embed lyrics: %v\n", err) diff --git a/backend/deezer.go b/backend/deezer.go deleted file mode 100644 index 2211ba0..0000000 --- a/backend/deezer.go +++ /dev/null @@ -1,451 +0,0 @@ -package backend - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -type DeezerDownloader struct { - client *http.Client -} - -type DeezerTrack struct { - ID int64 `json:"id"` - Title string `json:"title"` - TitleShort string `json:"title_short"` - Duration int `json:"duration"` - TrackPos int `json:"track_position"` - DiskNumber int `json:"disk_number"` - ISRC string `json:"isrc"` - ReleaseDate string `json:"release_date"` - Artist struct { - Name string `json:"name"` - ID int64 `json:"id"` - } `json:"artist"` - Album struct { - Title string `json:"title"` - ID int64 `json:"id"` - CoverXL string `json:"cover_xl"` - CoverBig string `json:"cover_big"` - } `json:"album"` - Contributors []struct { - Name string `json:"name"` - Role string `json:"role"` - } `json:"contributors"` -} - -type DeezMateResponse struct { - Success bool `json:"success"` - Links struct { - FLAC string `json:"flac"` - } `json:"links"` -} - -func NewDeezerDownloader() *DeezerDownloader { - return &DeezerDownloader{ - client: &http.Client{ - Timeout: 60 * time.Second, - }, - } -} - -func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) { - // Decode base64 API URL - 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), 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...") - - resp, err := d.client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to get Deezer URL: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("API returned status %d", resp.StatusCode) - } - - // Read body first to handle encoding issues - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - - if len(body) == 0 { - return "", fmt.Errorf("API returned empty response") - } - - var songLinkResp struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` - } - 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] + "..." - } - return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) - } - - 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 (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) { - // Extract track ID from Deezer URL - // Format: https://www.deezer.com/track/3412534581 - parts := strings.Split(deezerURL, "/track/") - if len(parts) < 2 { - return 0, fmt.Errorf("invalid Deezer URL format") - } - - // Get the track ID part and remove any query parameters - trackIDStr := strings.Split(parts[1], "?")[0] - trackIDStr = strings.TrimSpace(trackIDStr) - - var trackID int64 - _, err := fmt.Sscanf(trackIDStr, "%d", &trackID) - if err != nil { - return 0, fmt.Errorf("failed to parse track ID: %w", err) - } - - return trackID, nil -} - -func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) { - // Decode base64 API URL - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv") - url := fmt.Sprintf("%s%d", string(apiBase), trackID) - - resp, err := d.client.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to fetch track: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) - } - - // Read body first to handle encoding issues and provide better error messages - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if len(body) == 0 { - return nil, fmt.Errorf("API returned empty response") - } - - var track DeezerTrack - if err := json.Unmarshal(body, &track); err != nil { - // Truncate body for error message (max 200 chars) - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." - } - return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) - } - - if track.ID == 0 { - return nil, fmt.Errorf("track not found") - } - - return &track, nil -} - -func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) { - // Decode base64 API URL - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==") - url := fmt.Sprintf("%s%d", string(apiBase), trackID) - - resp, err := d.client.Get(url) - if err != nil { - return "", fmt.Errorf("failed to get download URL: %w", err) - } - 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) - } - - if len(body) == 0 { - return "", fmt.Errorf("API returned empty response") - } - - var apiResp DeezMateResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - // Truncate body for error message (max 200 chars) - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." - } - return "", fmt.Errorf("failed to decode API response: %w (response: %s)", err, bodyStr) - } - - if !apiResp.Success || apiResp.Links.FLAC == "" { - return "", fmt.Errorf("no FLAC download link available") - } - - return apiResp.Links.FLAC, nil -} - -func (d *DeezerDownloader) DownloadFile(url, filepath string) error { - // 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 - } - - resp, err := downloadClient.Get(url) - if err != nil { - return fmt.Errorf("failed to download file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed with status %d", resp.StatusCode) - } - - out, err := os.Create(filepath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - 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 -} - -func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error { - if coverURL == "" { - return fmt.Errorf("no cover URL provided") - } - - resp, err := d.client.Get(coverURL) - if err != nil { - return fmt.Errorf("failed to download cover: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("cover download failed with status %d", resp.StatusCode) - } - - out, err := os.Create(filepath) - if err != nil { - return fmt.Errorf("failed to create cover file: %w", err) - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - return err -} - -func buildFilename(title, artist string, trackNumber 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 - } - - // Check if format is a template (contains {}) - if strings.Contains(format, "{") { - filename = format - filename = strings.ReplaceAll(filename, "{title}", title) - filename = strings.ReplaceAll(filename, "{artist}", artist) - - // 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" - 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) - } - } - - return filename + ".flac" -} - -func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { - fmt.Printf("Using Deezer URL: %s\n", deezerURL) - - // Extract track ID from URL - trackID, err := d.GetTrackIDFromURL(deezerURL) - if err != nil { - return "", err - } - - // Get track info by ID - track, err := d.GetTrackByID(trackID) - if err != nil { - return "", err - } - - // Use Spotify metadata if provided, otherwise fallback to Deezer metadata - artists := spotifyArtistName - trackTitle := spotifyTrackName - albumTitle := spotifyAlbumName - - if artists == "" { - artists = track.Artist.Name - if len(track.Contributors) > 0 { - var mainArtists []string - for _, contrib := range track.Contributors { - if contrib.Role == "Main" { - mainArtists = append(mainArtists, contrib.Name) - } - } - if len(mainArtists) > 0 { - artists = strings.Join(mainArtists, ", ") - } - } - } - - if trackTitle == "" { - trackTitle = track.Title - } - - if albumTitle == "" { - albumTitle = track.Album.Title - } - - fmt.Printf("Found track: %s - %s\n", artists, trackTitle) - fmt.Printf("Album: %s\n", albumTitle) - - downloadURL, err := d.GetDownloadURL(track.ID) - if err != nil { - return "", err - } - - safeArtist := sanitizeFilename(artists) - safeTitle := sanitizeFilename(trackTitle) - - // Check if file with same ISRC already exists - if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists { - fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile) - return "EXISTS:" + existingFile, nil - } - - // Build filename based on format settings - filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) - filepath := filepath.Join(outputDir, filename) - - if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024)) - return "EXISTS:" + filepath, nil - } - - fmt.Println("Downloading FLAC file...") - if err := d.DownloadFile(downloadURL, filepath); err != nil { - return "", err - } - - fmt.Printf("Downloaded: %s\n", filepath) - - coverPath := "" - if track.Album.CoverXL != "" { - coverPath = filepath + ".cover.jpg" - fmt.Println("Downloading cover art...") - if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil { - fmt.Printf("Warning: Failed to download cover art: %v\n", err) - } else { - defer os.Remove(coverPath) - } - } - - fmt.Println("Embedding metadata and cover art...") - // Use album track number if in album folder structure, otherwise use playlist position - trackNumberToEmbed := 0 - if position > 0 { - if useAlbumTrackNumber && track.TrackPos > 0 { - trackNumberToEmbed = track.TrackPos - } else { - trackNumberToEmbed = position - } - } - - metadata := Metadata{ - Title: trackTitle, - Artist: artists, - Album: albumTitle, - Date: track.ReleaseDate, - TrackNumber: trackNumberToEmbed, - DiscNumber: track.DiskNumber, - ISRC: track.ISRC, - } - - if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { - return "", fmt.Errorf("failed to embed metadata: %w", err) - } - - fmt.Println("Metadata embedded successfully!") - fmt.Println("✓ Downloaded successfully from Deezer") - return filepath, nil -} - -func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { - // Get Deezer URL from Spotify track ID - deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID) - if err != nil { - return "", err - } - - return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) -} diff --git a/backend/metadata.go b/backend/metadata.go index aa1892a..9ca7172 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -18,11 +18,14 @@ type Metadata struct { Title string Artist string Album string - Date string + AlbumArtist string + Date string // Recorded date (year only) + ReleaseDate string // Release date (full date) TrackNumber int DiscNumber int ISRC string Lyrics string + Description string } func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { @@ -50,6 +53,9 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.Album != "" { _ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album) } + if metadata.AlbumArtist != "" { + _ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist) + } if metadata.Date != "" { _ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date) } @@ -62,6 +68,10 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.ISRC != "" { _ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC) } + 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 } @@ -120,6 +130,19 @@ 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 == "" { diff --git a/backend/qobuz.go b/backend/qobuz.go index 2771c41..df40451 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -307,7 +307,7 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in return filename + ".flac" } -func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool) (string, error) { fmt.Printf("Fetching track info for ISRC: %s\n", isrc) // Create output directory if it doesn't exist @@ -409,11 +409,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma fmt.Println("Embedding metadata and cover art...") - releaseYear := "" - if len(track.ReleaseDateOriginal) >= 4 { - releaseYear = track.ReleaseDateOriginal[:4] - } - // Use album track number if in album folder structure, otherwise use playlist position trackNumberToEmbed := 0 if position > 0 { @@ -422,16 +417,37 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma } else { trackNumberToEmbed = position } + } else if track.TrackNumber > 0 { + // Fallback to Qobuz track number if no position provided + trackNumberToEmbed = track.TrackNumber + } + + // Use Spotify release date if provided, otherwise use Qobuz release date + finalReleaseDate := spotifyReleaseDate + if finalReleaseDate == "" { + finalReleaseDate = track.ReleaseDateOriginal + } + + // Extract year from release date (format: YYYY-MM-DD or YYYY) + year := extractYear(finalReleaseDate) + + // Use Spotify album artist if provided, otherwise use Qobuz performer + finalAlbumArtist := spotifyAlbumArtist + if finalAlbumArtist == "" && track.Performer.Name != "" { + finalAlbumArtist = track.Performer.Name } metadata := Metadata{ Title: trackTitle, Artist: artists, Album: albumTitle, - Date: releaseYear, + AlbumArtist: finalAlbumArtist, + Date: year, // Recorded date (year only) + ReleaseDate: finalReleaseDate, // Release date (full date) TrackNumber: trackNumberToEmbed, DiscNumber: track.MediaNumber, ISRC: track.ISRC, + Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { diff --git a/backend/songlink.go b/backend/songlink.go index 6b7e9d1..f9cdcb6 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -19,7 +19,6 @@ type SongLinkClient struct { type SongLinkURLs struct { TidalURL string `json:"tidal_url"` - DeezerURL string `json:"deezer_url"` AmazonURL string `json:"amazon_url"` } @@ -27,11 +26,9 @@ type SongLinkURLs struct { type TrackAvailability struct { SpotifyID string `json:"spotify_id"` Tidal bool `json:"tidal"` - Deezer bool `json:"deezer"` Amazon bool `json:"amazon"` Qobuz bool `json:"qobuz"` TidalURL string `json:"tidal_url,omitempty"` - DeezerURL string `json:"deezer_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"` } @@ -154,12 +151,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink fmt.Printf("✓ Tidal URL found\n") } - // Extract Deezer URL - if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { - urls.DeezerURL = deezerLink.URL - fmt.Printf("✓ Deezer URL found\n") - } - // Extract Amazon URL if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { amazonURL := amazonLink.URL @@ -171,7 +162,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink } // Check if at least one URL was found - if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" { + if urls.TidalURL == "" && urls.AmazonURL == "" { return nil, fmt.Errorf("no streaming URLs found") } @@ -290,12 +281,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri availability.TidalURL = tidalLink.URL } - // Check Deezer - if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { - availability.Deezer = true - availability.DeezerURL = deezerLink.URL - } - // Check Amazon if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { availability.Amazon = true diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index aba2982..c628741 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -57,16 +57,18 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { // TrackMetadata mirrors the filtered track payload returned by the Python script. type TrackMetadata struct { + SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` Name string `json:"name"` AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` DurationMS int `json:"duration_ms"` Images string `json:"images"` ReleaseDate string `json:"release_date"` TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` - SpotifyID string `json:"spotify_id,omitempty"` } // ArtistSimple holds basic artist info for clickable artists @@ -78,17 +80,19 @@ type ArtistSimple struct { // AlbumTrackMetadata holds per-track info for album / playlist formatting. type AlbumTrackMetadata struct { + SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` Name string `json:"name"` AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` DurationMS int `json:"duration_ms"` Images string `json:"images"` ReleaseDate string `json:"release_date"` TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` AlbumType string `json:"album_type,omitempty"` - SpotifyID string `json:"spotify_id,omitempty"` AlbumID string `json:"album_id,omitempty"` AlbumURL string `json:"album_url,omitempty"` ArtistID string `json:"artist_id,omitempty"` @@ -227,6 +231,7 @@ type trackSimplified struct { Name string `json:"name"` DurationMS int `json:"duration_ms"` TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` ExternalURL externalURL `json:"external_urls"` Artists []artist `json:"artists"` } @@ -236,6 +241,7 @@ type trackFull struct { Name string `json:"name"` DurationMS int `json:"duration_ms"` TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` ExternalURL externalURL `json:"external_urls"` ExternalID externalID `json:"external_ids"` Album albumSimplified `json:"album"` @@ -502,16 +508,18 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes }) } tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: item.Track.ID, Artists: joinArtists(item.Track.Artists), Name: item.Track.Name, AlbumName: item.Track.Album.Name, + AlbumArtist: joinArtists(item.Track.Album.Artists), DurationMS: item.Track.DurationMS, Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images), ReleaseDate: item.Track.Album.ReleaseDate, TrackNumber: item.Track.TrackNumber, + DiscNumber: item.Track.DiscNumber, ExternalURL: item.Track.ExternalURL.Spotify, ISRC: item.Track.ExternalID.ISRC, - SpotifyID: item.Track.ID, AlbumID: item.Track.Album.ID, AlbumURL: item.Track.Album.ExternalURL.Spotify, ArtistID: artistID, @@ -551,16 +559,18 @@ func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumR for _, item := range raw.Data.Tracks.Items { isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache) tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: item.ID, Artists: joinArtists(item.Artists), Name: item.Name, AlbumName: raw.Data.Name, + AlbumArtist: joinArtists(raw.Data.Artists), DurationMS: item.DurationMS, Images: albumImage, ReleaseDate: raw.Data.ReleaseDate, TrackNumber: item.TrackNumber, + DiscNumber: item.DiscNumber, ExternalURL: item.ExternalURL.Spotify, ISRC: isrc, - SpotifyID: item.ID, }) } @@ -629,17 +639,19 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, }) } allTracks = append(allTracks, AlbumTrackMetadata{ + SpotifyID: tr.ID, Artists: joinArtists(tr.Artists), Name: tr.Name, AlbumName: alb.Name, + AlbumArtist: joinArtists(alb.Artists), AlbumType: alb.AlbumType, DurationMS: tr.DurationMS, Images: albumImage, ReleaseDate: alb.ReleaseDate, TrackNumber: tr.TrackNumber, + DiscNumber: tr.DiscNumber, ExternalURL: tr.ExternalURL.Spotify, ISRC: isrc, - SpotifyID: tr.ID, AlbumID: alb.ID, AlbumURL: alb.ExternalURL.Spotify, ArtistID: artistID, @@ -676,16 +688,18 @@ func formatTrackData(raw *trackFull) TrackResponse { } return TrackResponse{ Track: TrackMetadata{ + SpotifyID: raw.ID, Artists: joinArtists(raw.Artists), Name: raw.Name, AlbumName: raw.Album.Name, + AlbumArtist: joinArtists(raw.Album.Artists), DurationMS: raw.DurationMS, Images: firstImageURL(raw.Album.Images), ReleaseDate: raw.Album.ReleaseDate, TrackNumber: raw.TrackNumber, + DiscNumber: raw.DiscNumber, ExternalURL: raw.ExternalURL.Spotify, ISRC: raw.ExternalID.ISRC, - SpotifyID: raw.ID, }, } } diff --git a/backend/tidal.go b/backend/tidal.go index 95be9fa..a4b032f 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -801,7 +801,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } -func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -848,7 +848,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo artistName = strings.Join(artists, ", ") } } - artistName = sanitizeFilename(artistName) if trackTitle == "" { trackTitle = trackInfo.Title @@ -856,20 +855,23 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo trackTitle = fmt.Sprintf("track_%d", trackInfo.ID) } } - trackTitle = sanitizeFilename(trackTitle) if albumTitle == "" { albumTitle = trackInfo.Album.Title } + // Sanitize for filename only (not for metadata) + artistNameForFile := sanitizeFilename(artistName) + trackTitleForFile := sanitizeFilename(trackTitle) + // Check if file with same ISRC already exists if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) return "EXISTS:" + existingFile, nil } - // Build filename based on format settings - filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + // Build filename based on format settings (use sanitized versions for filename) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { @@ -905,11 +907,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } } - releaseYear := "" - if len(trackInfo.Album.ReleaseDate) >= 4 { - releaseYear = trackInfo.Album.ReleaseDate[:4] - } - // Use album track number if in album folder structure, otherwise use playlist position trackNumberToEmbed := 0 if position > 0 { @@ -918,16 +915,37 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } else { trackNumberToEmbed = position } + } else if trackInfo.TrackNumber > 0 { + // Fallback to Tidal track number if no position provided + trackNumberToEmbed = trackInfo.TrackNumber + } + + // Use Spotify release date if provided, otherwise use Tidal release date + finalReleaseDate := spotifyReleaseDate + if finalReleaseDate == "" { + finalReleaseDate = trackInfo.Album.ReleaseDate + } + + // Extract year from release date (format: YYYY-MM-DD or YYYY) + year := extractYear(finalReleaseDate) + + // Use Spotify album artist if provided, otherwise use first artist from Tidal + finalAlbumArtist := spotifyAlbumArtist + if finalAlbumArtist == "" && len(trackInfo.Artists) > 0 { + finalAlbumArtist = trackInfo.Artists[0].Name } metadata := Metadata{ Title: trackTitle, Artist: artistName, Album: albumTitle, - Date: releaseYear, + AlbumArtist: finalAlbumArtist, + Date: year, // Recorded date (year only) + ReleaseDate: finalReleaseDate, // Release date (full date) TrackNumber: trackNumberToEmbed, DiscNumber: trackInfo.VolumeNumber, ISRC: trackInfo.ISRC, + Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -941,7 +959,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return outputFilename, nil } -func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -992,7 +1010,6 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality artistName = strings.Join(artists, ", ") } } - artistName = sanitizeFilename(artistName) if trackTitle == "" { trackTitle = trackInfo.Title @@ -1000,19 +1017,22 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality trackTitle = fmt.Sprintf("track_%d", trackInfo.ID) } } - trackTitle = sanitizeFilename(trackTitle) if albumTitle == "" { albumTitle = trackInfo.Album.Title } + // Sanitize for filename only (not for metadata) + artistNameForFile := sanitizeFilename(artistName) + trackTitleForFile := sanitizeFilename(trackTitle) + // Check if file with same ISRC already exists if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) return "EXISTS:" + existingFile, nil } - filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { @@ -1051,11 +1071,6 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } } - releaseYear := "" - if len(trackInfo.Album.ReleaseDate) >= 4 { - releaseYear = trackInfo.Album.ReleaseDate[:4] - } - trackNumberToEmbed := 0 if position > 0 { if useAlbumTrackNumber && trackInfo.TrackNumber > 0 { @@ -1063,16 +1078,37 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } else { trackNumberToEmbed = position } + } else if trackInfo.TrackNumber > 0 { + // Fallback to Tidal track number if no position provided + trackNumberToEmbed = trackInfo.TrackNumber + } + + // Use Spotify release date if provided, otherwise use Tidal release date + finalReleaseDate := spotifyReleaseDate + if finalReleaseDate == "" { + finalReleaseDate = trackInfo.Album.ReleaseDate + } + + // Extract year from release date (format: YYYY-MM-DD or YYYY) + year := extractYear(finalReleaseDate) + + // Use Spotify album artist if provided, otherwise use first artist from Tidal + finalAlbumArtist := spotifyAlbumArtist + if finalAlbumArtist == "" && len(trackInfo.Artists) > 0 { + finalAlbumArtist = trackInfo.Artists[0].Name } metadata := Metadata{ Title: trackTitle, Artist: artistName, Album: albumTitle, - Date: releaseYear, + AlbumArtist: finalAlbumArtist, + Date: year, // Recorded date (year only) + ReleaseDate: finalReleaseDate, // Release date (full date) TrackNumber: trackNumberToEmbed, DiscNumber: trackInfo.VolumeNumber, ISRC: trackInfo.ISRC, + Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -1086,7 +1122,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return outputFilename, nil } -func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool) (string, error) { // Get Tidal URL from Spotify track ID tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { @@ -1096,11 +1132,11 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF return t.DownloadBySearch(spotifyTrackName, spotifyArtistName, spotifyAlbumName, "", 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) } - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber) } // DownloadWithISRC downloads a track with ISRC matching for search fallback -func (t *TidalDownloader) DownloadWithISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool, expectedDuration int) (string, error) { +func (t *TidalDownloader) DownloadWithISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, expectedDuration int) (string, error) { // Get Tidal URL from Spotify track ID tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { @@ -1110,7 +1146,7 @@ func (t *TidalDownloader) DownloadWithISRC(spotifyTrackID, spotifyISRC, outputDi return t.DownloadBySearchWithISRC(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) } - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber) } // DownloadBySearch downloads a track by searching Tidal directly using metadata @@ -1159,7 +1195,6 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN finalArtistName = "Unknown Artist" } } - finalArtistName = sanitizeFilename(finalArtistName) if finalTrackTitle == "" { finalTrackTitle = trackInfo.Title @@ -1167,12 +1202,15 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID) } } - finalTrackTitle = sanitizeFilename(finalTrackTitle) if finalAlbumTitle == "" { finalAlbumTitle = trackInfo.Album.Title } + // Sanitize for filename only (not for metadata) + finalArtistNameForFile := sanitizeFilename(finalArtistName) + finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) + // Check if file with same ISRC already exists if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) @@ -1180,7 +1218,7 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN } // Build filename - filename := buildTidalFilename(finalTrackTitle, finalArtistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { @@ -1217,11 +1255,6 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN } } - releaseYear := "" - if len(trackInfo.Album.ReleaseDate) >= 4 { - releaseYear = trackInfo.Album.ReleaseDate[:4] - } - trackNumberToEmbed := 0 if position > 0 { if useAlbumTrackNumber && trackInfo.TrackNumber > 0 { @@ -1229,16 +1262,34 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN } else { trackNumberToEmbed = position } + } else if trackInfo.TrackNumber > 0 { + // Fallback to Tidal track number if no position provided + trackNumberToEmbed = trackInfo.TrackNumber + } + + // Use Tidal release date (no Spotify metadata available in search fallback) + finalReleaseDate := trackInfo.Album.ReleaseDate + + // Extract year from release date (format: YYYY-MM-DD or YYYY) + year := extractYear(finalReleaseDate) + + // Use first artist from Tidal as album artist in search fallback + albumArtist := "" + if len(trackInfo.Artists) > 0 { + albumArtist = trackInfo.Artists[0].Name } metadata := Metadata{ Title: finalTrackTitle, Artist: finalArtistName, Album: finalAlbumTitle, - Date: releaseYear, + AlbumArtist: albumArtist, + Date: year, // Recorded date (year only) + ReleaseDate: finalReleaseDate, // Release date (full date) TrackNumber: trackNumberToEmbed, DiscNumber: trackInfo.VolumeNumber, ISRC: trackInfo.ISRC, + Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -1516,7 +1567,6 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al finalArtistName = "Unknown Artist" } } - finalArtistName = sanitizeFilename(finalArtistName) if finalTrackTitle == "" { finalTrackTitle = trackInfo.Title @@ -1524,19 +1574,22 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID) } } - finalTrackTitle = sanitizeFilename(finalTrackTitle) if finalAlbumTitle == "" { finalAlbumTitle = trackInfo.Album.Title } + // Sanitize for filename only (not for metadata) + finalArtistNameForFile := sanitizeFilename(finalArtistName) + finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) + // Check if file already exists if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) return "EXISTS:" + existingFile, nil } - filename := buildTidalFilename(finalTrackTitle, finalArtistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { @@ -1576,11 +1629,6 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al } } - releaseYear := "" - if len(trackInfo.Album.ReleaseDate) >= 4 { - releaseYear = trackInfo.Album.ReleaseDate[:4] - } - trackNumberToEmbed := 0 if position > 0 { if useAlbumTrackNumber && trackInfo.TrackNumber > 0 { @@ -1588,16 +1636,34 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al } else { trackNumberToEmbed = position } + } else if trackInfo.TrackNumber > 0 { + // Fallback to Tidal track number if no position provided + trackNumberToEmbed = trackInfo.TrackNumber + } + + // Use Tidal release date (no Spotify metadata available in search fallback) + finalReleaseDate := trackInfo.Album.ReleaseDate + + // Extract year from release date (format: YYYY-MM-DD or YYYY) + year := extractYear(finalReleaseDate) + + // Use first artist from Tidal as album artist in search fallback + albumArtist := "" + if len(trackInfo.Artists) > 0 { + albumArtist = trackInfo.Artists[0].Name } metadata := Metadata{ Title: finalTrackTitle, Artist: finalArtistName, Album: finalAlbumTitle, - Date: releaseYear, + AlbumArtist: albumArtist, + Date: year, // Recorded date (year only) + ReleaseDate: finalReleaseDate, // Release date (full date) TrackNumber: trackNumberToEmbed, DiscNumber: trackInfo.VolumeNumber, ISRC: trackInfo.ISRC, + Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -1611,7 +1677,7 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al return outputFilename, nil } -func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool) (string, error) { // Get Tidal URL once tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { @@ -1622,12 +1688,12 @@ func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, qualit } // Use parallel API requests via DownloadByURLWithFallback - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber) } // DownloadWithFallbackAndISRC downloads with ISRC matching for search fallback // Uses parallel API requests for faster download -func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool, expectedDuration int) (string, error) { +func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, expectedDuration int) (string, error) { // Get Tidal URL once tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { @@ -1638,7 +1704,7 @@ func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISR } // Use parallel API requests via DownloadByURLWithFallback - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber) } func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 5d27c86..090e1b2 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -58,7 +58,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {

- Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required. + Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.

diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index ac1eafb..6b1dfc4 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -7,12 +7,6 @@ export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => ); -export const DeezerIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( - - - -); - export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 9f6a0c9..4eca157 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -26,12 +26,6 @@ const TidalIcon = () => ( ); -const DeezerIcon = () => ( - - - -); - const QobuzIcon = () => ( @@ -224,7 +218,7 @@ export function SettingsPage() {