package backend import ( "encoding/base64" "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "time" ) type TidalDownloader struct { client *http.Client timeout time.Duration maxRetries int clientID string clientSecret string apiURL string } type TidalSearchResponse struct { Limit int `json:"limit"` Offset int `json:"offset"` TotalNumberOfItems int `json:"totalNumberOfItems"` Items []TidalTrack `json:"items"` } type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` ISRC string `json:"isrc"` AudioQuality string `json:"audioQuality"` TrackNumber int `json:"trackNumber"` VolumeNumber int `json:"volumeNumber"` Duration int `json:"duration"` Copyright string `json:"copyright"` Explicit bool `json:"explicit"` Album struct { Title string `json:"title"` Cover string `json:"cover"` ReleaseDate string `json:"releaseDate"` } `json:"album"` Artists []struct { Name string `json:"name"` } `json:"artists"` Artist struct { Name string `json:"name"` } `json:"artist"` MediaMetadata struct { Tags []string `json:"tags"` } `json:"mediaMetadata"` } type TidalAPIResponse struct { OriginalTrackURL string `json:"OriginalTrackUrl"` } // TidalAPIResponseV2 is the new API response format (version 2.0) type TidalAPIResponseV2 struct { Version string `json:"version"` Data struct { TrackID int64 `json:"trackId"` AssetPresentation string `json:"assetPresentation"` AudioMode string `json:"audioMode"` AudioQuality string `json:"audioQuality"` ManifestMimeType string `json:"manifestMimeType"` ManifestHash string `json:"manifestHash"` Manifest string `json:"manifest"` BitDepth int `json:"bitDepth"` SampleRate int `json:"sampleRate"` } `json:"data"` } type TidalAPIInfo struct { URL string `json:"url"` Status string `json:"status"` } // TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format type TidalBTSManifest struct { MimeType string `json:"mimeType"` Codecs string `json:"codecs"` EncryptionType string `json:"encryptionType"` URLs []string `json:"urls"` } func NewTidalDownloader(apiURL string) *TidalDownloader { clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") // If apiURL is empty, try to get first available API if apiURL == "" { downloader := &TidalDownloader{ client: &http.Client{ Timeout: 5 * time.Second, }, timeout: 5 * time.Second, maxRetries: 3, clientID: string(clientID), clientSecret: string(clientSecret), apiURL: "", } // Try to get available APIs apis, err := downloader.GetAvailableAPIs() if err == nil && len(apis) > 0 { apiURL = apis[0] } } return &TidalDownloader{ client: &http.Client{ Timeout: 5 * time.Second, }, timeout: 5 * time.Second, maxRetries: 3, clientID: string(clientID), clientSecret: string(clientSecret), apiURL: apiURL, } } func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { // Decode base64 API URL apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==") // Add cache-busting parameter with current timestamp urlWithCacheBust := fmt.Sprintf("%s?t=%d", string(apiURL), time.Now().Unix()) // Create request with cache bypass headers req, err := http.NewRequest("GET", urlWithCacheBust, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Add headers to bypass cache req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") req.Header.Set("Pragma", "no-cache") req.Header.Set("Expires", "0") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch API list: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("failed to fetch API list: HTTP %d", resp.StatusCode) } var apiList []string if err := json.NewDecoder(resp.Body).Decode(&apiList); err != nil { return nil, fmt.Errorf("failed to decode API list: %w", err) } var apis []string for _, api := range apiList { apis = append(apis, "https://"+api) } return apis, nil } func (t *TidalDownloader) GetAccessToken() (string, error) { data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) // Decode base64 API URL authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=") req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data)) if err != nil { return "", err } req.SetBasicAuth(t.clientID, t.clientSecret) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := t.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode) } var result struct { AccessToken string `json:"access_token"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } return result.AccessToken, nil } // SearchTracks searches for tracks on Tidal with configurable limit func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) { return t.SearchTracksWithLimit(query, 50) // Default to 50 results for better matching } // SearchTracksWithLimit searches for tracks on Tidal with a specific limit func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*TidalSearchResponse, error) { token, err := t.GetAccessToken() if err != nil { return nil, fmt.Errorf("failed to get access token: %w", err) } // Decode base64 API URL and encode the query parameter searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=%d&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query), limit) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) resp, err := t.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body)) } var result TidalSearchResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } return &result, nil } // SearchTrackByMetadata searches for a track using artist name and track name // It tries multiple search strategies including romaji conversion for Japanese text // Now accepts ISRC for exact matching func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string, expectedDuration int) (*TidalTrack, error) { return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", expectedDuration) } // SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { // Build search queries - multiple strategies queries := []string{} // Strategy 1: Artist + Track name (original) if artistName != "" && trackName != "" { queries = append(queries, artistName+" "+trackName) } // Strategy 2: Track name only (sometimes works better) if trackName != "" { queries = append(queries, trackName) } // Strategy 3: Romaji versions if Japanese detected if ContainsJapanese(trackName) || ContainsJapanese(artistName) { // Convert to romaji (hiragana/katakana only, kanji stays) romajiTrack := JapaneseToRomaji(trackName) romajiArtist := JapaneseToRomaji(artistName) // Clean and remove ALL non-ASCII characters (including kanji) cleanRomajiTrack := cleanToASCII(romajiTrack) cleanRomajiArtist := cleanToASCII(romajiArtist) // Artist + Track romaji (cleaned to ASCII only) if cleanRomajiArtist != "" && cleanRomajiTrack != "" { romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack if !containsQuery(queries, romajiQuery) { queries = append(queries, romajiQuery) fmt.Printf("Japanese detected, adding romaji query: %s\n", romajiQuery) } } // Track romaji only (cleaned) if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { if !containsQuery(queries, cleanRomajiTrack) { queries = append(queries, cleanRomajiTrack) } } // Also try with partial romaji (artist + cleaned track) if artistName != "" && cleanRomajiTrack != "" { partialQuery := artistName + " " + cleanRomajiTrack if !containsQuery(queries, partialQuery) { queries = append(queries, partialQuery) } } } // Strategy 4: Artist only as last resort if artistName != "" { artistOnly := cleanToASCII(JapaneseToRomaji(artistName)) if artistOnly != "" && !containsQuery(queries, artistOnly) { queries = append(queries, artistOnly) } } // Collect all search results from all queries var allTracks []TidalTrack searchedQueries := make(map[string]bool) for _, query := range queries { cleanQuery := strings.TrimSpace(query) if cleanQuery == "" || searchedQueries[cleanQuery] { continue } searchedQueries[cleanQuery] = true fmt.Printf("Searching Tidal for: %s\n", cleanQuery) result, err := t.SearchTracksWithLimit(cleanQuery, 100) // Get more results if err != nil { fmt.Printf("Search error for '%s': %v\n", cleanQuery, err) continue } if len(result.Items) > 0 { fmt.Printf("Found %d results for '%s'\n", len(result.Items), cleanQuery) allTracks = append(allTracks, result.Items...) } } if len(allTracks) == 0 { return nil, fmt.Errorf("no tracks found for any search query") } // Priority 1: Match by ISRC (exact match) if spotifyISRC != "" { fmt.Printf("Looking for ISRC match: %s\n", spotifyISRC) for i := range allTracks { track := &allTracks[i] if track.ISRC == spotifyISRC { fmt.Printf("✓ ISRC match found: %s - %s (ISRC: %s, Quality: %s)\n", track.Artist.Name, track.Title, track.ISRC, track.AudioQuality) return track, nil } } fmt.Printf("No exact ISRC match found, trying other matching methods...\n") } // If ISRC was provided but no match found, return error - don't download wrong track if spotifyISRC != "" { fmt.Printf("✗ No ISRC match found for: %s\n", spotifyISRC) fmt.Printf(" Available ISRCs from search results:\n") // Show first 5 results for debugging for i, track := range allTracks { if i >= 5 { fmt.Printf(" ... and %d more results\n", len(allTracks)-5) break } fmt.Printf(" - %s - %s (ISRC: %s)\n", track.Artist.Name, track.Title, track.ISRC) } return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) } // Only proceed without ISRC matching if no ISRC was provided // Priority 2: Match by duration (within tolerance) + prefer best quality var bestMatch *TidalTrack if expectedDuration > 0 { tolerance := 3 // 3 seconds tolerance var durationMatches []*TidalTrack for i := range allTracks { track := &allTracks[i] durationDiff := track.Duration - expectedDuration if durationDiff < 0 { durationDiff = -durationDiff } if durationDiff <= tolerance { durationMatches = append(durationMatches, track) } } if len(durationMatches) > 0 { // Find best quality among duration matches bestMatch = durationMatches[0] for _, track := range durationMatches { for _, tag := range track.MediaMetadata.Tags { if tag == "HIRES_LOSSLESS" { bestMatch = track break } } } fmt.Printf("Found via duration match: %s - %s (%s)\n", bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality) return bestMatch, nil } } // Priority 3: Just take the best quality from first results (only when no ISRC provided) bestMatch = &allTracks[0] for i := range allTracks { track := &allTracks[i] for _, tag := range track.MediaMetadata.Tags { if tag == "HIRES_LOSSLESS" { bestMatch = track break } } if bestMatch != &allTracks[0] { break // Found HIRES_LOSSLESS } } fmt.Printf("Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n", bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality) return bestMatch, nil } // containsQuery checks if a query already exists in the list func containsQuery(queries []string, query string) bool { for _, q := range queries { if q == query { return true } } return false } func (t *TidalDownloader) GetTidalURLFromSpotify(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), 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 Tidal URL...") resp, err := t.client.Do(req) if err != nil { return "", fmt.Errorf("failed to get Tidal URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("API returned status %d", resp.StatusCode) } 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) } tidalLink, ok := songLinkResp.LinksByPlatform["tidal"] if !ok || tidalLink.URL == "" { return "", fmt.Errorf("tidal link not found") } tidalURL := tidalLink.URL fmt.Printf("Found Tidal URL: %s\n", tidalURL) return tidalURL, nil } func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { // Extract track ID from Tidal URL // Format: https://listen.tidal.com/track/441821360 // or: https://tidal.com/browse/track/123456789 parts := strings.Split(tidalURL, "/track/") if len(parts) < 2 { return 0, fmt.Errorf("invalid tidal 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 (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { token, err := t.GetAccessToken() if err != nil { return nil, fmt.Errorf("failed to get access token: %w", err) } // Decode base64 API URL trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=") trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID) req, err := http.NewRequest("GET", trackURL, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) resp, err := t.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("failed to get track info: HTTP %d - %s", resp.StatusCode, string(body)) } var trackInfo TidalTrack if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil { return nil, err } fmt.Printf("Found: %s (%s)\n", trackInfo.Title, trackInfo.AudioQuality) return &trackInfo, nil } func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { fmt.Println("Fetching URL...") url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) fmt.Printf("Tidal API URL: %s\n", url) resp, err := t.client.Get(url) if err != nil { fmt.Printf("✗ Tidal API request failed: %v\n", err) return "", fmt.Errorf("failed to get download URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode) return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) } // Read body to try both formats body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("✗ Failed to read response body: %v\n", err) return "", fmt.Errorf("failed to read response: %w", err) } // Try v2 format first (object with manifest) var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { fmt.Println("✓ Tidal manifest found (v2 API)") return "MANIFEST:" + v2Response.Data.Manifest, nil } // Fallback to v1 format (array with OriginalTrackUrl) var apiResponses []TidalAPIResponse if err := json.Unmarshal(body, &apiResponses); err != nil { // Truncate body for error message (max 200 chars) bodyStr := string(body) if len(bodyStr) > 200 { bodyStr = bodyStr[:200] + "..." } fmt.Printf("✗ Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr) return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } if len(apiResponses) == 0 { fmt.Println("✗ Tidal API returned empty response") return "", fmt.Errorf("no download URL in response") } for _, item := range apiResponses { if item.OriginalTrackURL != "" { fmt.Println("✓ Tidal download URL found") return item.OriginalTrackURL, nil } } fmt.Println("✗ No valid download URL in Tidal API response") return "", fmt.Errorf("download URL not found in response") } func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) { albumID = strings.ReplaceAll(albumID, "-", "/") // Decode base64 API URL imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=") artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID) resp, err := t.client.Get(artURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("failed to download album art: HTTP %d", resp.StatusCode) } return io.ReadAll(resp.Body) } func (t *TidalDownloader) DownloadFile(url, filepath string) error { // Check if this is a manifest-based download if strings.HasPrefix(url, "MANIFEST:") { return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath) } resp, err := t.client.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() // 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)) fmt.Println("Download complete") return nil } // DownloadFromManifest downloads audio from manifest (supports BTS and DASH formats) func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error { directURL, initURL, mediaURLs, err := parseManifest(manifestB64) if err != nil { return fmt.Errorf("failed to parse manifest: %w", err) } // Create HTTP client with longer timeout client := &http.Client{ Timeout: 120 * time.Second, } // If we have a direct URL (BTS format), download directly if directURL != "" { fmt.Println("Downloading file...") resp, err := client.Get(directURL) 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(outputPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer out.Close() // 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) } fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) fmt.Println("Download complete") return nil } // DASH format - download segments to temporary M4A file, then remux to FLAC fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1) // Create temporary file for M4A segments tempPath := outputPath + ".m4a.tmp" out, err := os.Create(tempPath) if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } // Download initialization segment fmt.Print("Downloading init segment... ") resp, err := client.Get(initURL) if err != nil { out.Close() os.Remove(tempPath) return fmt.Errorf("failed to download init segment: %w", err) } if resp.StatusCode != 200 { resp.Body.Close() out.Close() os.Remove(tempPath) return fmt.Errorf("init segment download failed with status %d", resp.StatusCode) } _, err = io.Copy(out, resp.Body) resp.Body.Close() if err != nil { out.Close() os.Remove(tempPath) return fmt.Errorf("failed to write init segment: %w", err) } fmt.Println("OK") // Download media segments with progress tracking totalSegments := len(mediaURLs) var totalBytes int64 lastTime := time.Now() var lastBytes int64 for i, mediaURL := range mediaURLs { resp, err := client.Get(mediaURL) if err != nil { out.Close() os.Remove(tempPath) return fmt.Errorf("failed to download segment %d: %w", i+1, err) } if resp.StatusCode != 200 { resp.Body.Close() out.Close() os.Remove(tempPath) return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode) } n, err := io.Copy(out, resp.Body) totalBytes += n resp.Body.Close() if err != nil { out.Close() os.Remove(tempPath) return fmt.Errorf("failed to write segment %d: %w", i+1, err) } // Calculate speed and update progress for frontend mbDownloaded := float64(totalBytes) / (1024 * 1024) now := time.Now() timeDiff := now.Sub(lastTime).Seconds() var speedMBps float64 if timeDiff > 0.1 { // Update speed every 100ms bytesDiff := float64(totalBytes - lastBytes) speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff SetDownloadSpeed(speedMBps) lastTime = now lastBytes = totalBytes } SetDownloadProgress(mbDownloaded) // Show progress with size in terminal fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments) } // Close temp file before remuxing out.Close() // Get temp file size tempInfo, _ := os.Stat(tempPath) fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024)) // Remux M4A to FLAC using ffmpeg // DASH segments are in fMP4 container with FLAC codec, need to extract to native FLAC fmt.Println("Converting to FLAC...") cmd := exec.Command("ffmpeg", "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath) var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // If ffmpeg fails, try to keep the M4A file for debugging m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" os.Rename(tempPath, m4aPath) return fmt.Errorf("ffmpeg conversion failed (M4A saved as %s): %w - %s", m4aPath, err, stderr.String()) } // Remove temp file os.Remove(tempPath) fmt.Println("Download complete") return nil } func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) } } fmt.Printf("Using Tidal URL: %s\n", tidalURL) // Extract track ID from URL trackID, err := t.GetTrackIDFromURL(tidalURL) if err != nil { return "", err } // Get track info by ID trackInfo, err := t.GetTrackInfoByID(trackID) if err != nil { return "", err } if trackInfo.ID == 0 { return "", fmt.Errorf("no track ID found") } // All metadata from Spotify - no fallback to Tidal artistName := spotifyArtistName trackTitle := spotifyTrackName albumTitle := spotifyAlbumName // 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 (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 { fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) return "EXISTS:" + outputFilename, nil } downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality) if err != nil { return "", err } fmt.Printf("Downloading to: %s\n", outputFilename) if err := t.DownloadFile(downloadURL, outputFilename); err != nil { return "", err } fmt.Println("Adding metadata...") coverPath := "" // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify if spotifyCoverURL != "" { coverPath = outputFilename + ".cover.jpg" coverClient := NewCoverClient() if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) coverPath = "" } else { defer os.Remove(coverPath) fmt.Println("Spotify cover downloaded") } } // Determine track number to embed - ALL from Spotify // - If position > 0 and !useAlbumTrackNumber: use playlist position // - Otherwise: use Spotify track number trackNumberToEmbed := spotifyTrackNumber if position > 0 && !useAlbumTrackNumber { trackNumberToEmbed = position // Use playlist position } // ALL metadata from Spotify metadata := Metadata{ Title: trackTitle, Artist: artistName, Album: albumTitle, AlbumArtist: spotifyAlbumArtist, Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) TrackNumber: trackNumberToEmbed, TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify DiscNumber: spotifyDiscNumber, // Disc number from Spotify ISRC: spotifyISRC, // ISRC from Spotify Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { fmt.Printf("Tagging failed: %v\n", err) } else { fmt.Println("Metadata saved") } fmt.Println("Done") fmt.Println("✓ Downloaded successfully from Tidal") return outputFilename, nil } func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) } if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) } } fmt.Printf("Using Tidal URL: %s\n", tidalURL) // Extract track ID from URL trackID, err := t.GetTrackIDFromURL(tidalURL) if err != nil { return "", err } // Get track info by ID trackInfo, err := t.GetTrackInfoByID(trackID) if err != nil { return "", err } if trackInfo.ID == 0 { return "", fmt.Errorf("no track ID found") } // All metadata from Spotify - no fallback to Tidal artistName := spotifyArtistName trackTitle := spotifyTrackName albumTitle := spotifyAlbumName // 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(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) return "EXISTS:" + outputFilename, nil } // Request download URL from ALL APIs in parallel - use first success successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) if err != nil { return "", err } // Download the file fmt.Printf("Downloading to: %s\n", outputFilename) downloader := NewTidalDownloader(successAPI) if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { return "", err } fmt.Println("Adding metadata...") coverPath := "" // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify if spotifyCoverURL != "" { coverPath = outputFilename + ".cover.jpg" coverClient := NewCoverClient() if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) coverPath = "" } else { defer os.Remove(coverPath) fmt.Println("Spotify cover downloaded") } } // Determine track number to embed - ALL from Spotify trackNumberToEmbed := spotifyTrackNumber if position > 0 && !useAlbumTrackNumber { trackNumberToEmbed = position // Use playlist position } // ALL metadata from Spotify metadata := Metadata{ Title: trackTitle, Artist: artistName, Album: albumTitle, AlbumArtist: spotifyAlbumArtist, Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) TrackNumber: trackNumberToEmbed, TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify DiscNumber: spotifyDiscNumber, // Disc number from Spotify ISRC: spotifyISRC, // ISRC from Spotify Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { fmt.Printf("Tagging failed: %v\n", err) } else { fmt.Println("Metadata saved") } fmt.Println("Done") fmt.Println("✓ Downloaded successfully from Tidal") return outputFilename, nil } func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) { // Get Tidal URL from Spotify track ID tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { // Songlink failed to find Tidal URL, try search fallback fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) fmt.Println("Trying Tidal search fallback...") return t.DownloadBySearch(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks) } return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) } // 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, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, expectedDuration int, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) { // Get Tidal URL from Spotify track ID tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { // Songlink failed to find Tidal URL, try search fallback with ISRC fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) fmt.Println("Trying Tidal search fallback with ISRC matching...") return t.DownloadBySearchWithISRC(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks) } return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) } // DownloadBySearch downloads a track by searching Tidal directly using metadata // This is used as a fallback when Songlink API doesn't find a Tidal URL func (t *TidalDownloader) DownloadBySearch(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) { return t.DownloadBySearchWithISRC(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks) } // DownloadBySearchWithISRC downloads a track by searching Tidal with ISRC matching func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) } } // Search for the track with ISRC matching trackInfo, err := t.SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC, expectedDuration) if err != nil { return "", fmt.Errorf("search fallback failed: %w", err) } if trackInfo.ID == 0 { return "", fmt.Errorf("no track ID found from search") } // All metadata from Spotify - no fallback to Tidal finalArtistName := artistName finalTrackTitle := trackName finalAlbumTitle := albumName // Sanitize for filename only (not for metadata) finalArtistNameForFile := sanitizeFilename(finalArtistName) finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) // Check if file with same ISRC already exists (use Spotify ISRC) if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists { fmt.Printf("File with ISRC %s already exists: %s\n", spotifyISRC, existingFile) return "EXISTS:" + existingFile, nil } // Build filename filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) return "EXISTS:" + outputFilename, nil } // Get download URL downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality) if err != nil { return "", err } fmt.Printf("Downloading to: %s\n", outputFilename) if err := t.DownloadFile(downloadURL, outputFilename); err != nil { return "", err } fmt.Println("Adding metadata...") coverPath := "" // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify if spotifyCoverURL != "" { coverPath = outputFilename + ".cover.jpg" coverClient := NewCoverClient() if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) coverPath = "" } else { defer os.Remove(coverPath) fmt.Println("Spotify cover downloaded") } } // Determine track number to embed - ALL from Spotify trackNumberToEmbed := spotifyTrackNumber if position > 0 && !useAlbumTrackNumber { trackNumberToEmbed = position // Use playlist position } // ALL metadata from Spotify metadata := Metadata{ Title: finalTrackTitle, Artist: finalArtistName, Album: finalAlbumTitle, AlbumArtist: albumArtist, Date: releaseDate, // Recorded date (full date YYYY-MM-DD) TrackNumber: trackNumberToEmbed, TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify DiscNumber: spotifyDiscNumber, // Disc number from Spotify ISRC: spotifyISRC, // ISRC from Spotify Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { fmt.Printf("Tagging failed: %v\n", err) } else { fmt.Println("Metadata saved") } fmt.Println("Done") fmt.Println("✓ Downloaded successfully from Tidal (via search)") return outputFilename, nil } // DASH MPD XML structures for parsing manifest type MPD struct { XMLName xml.Name `xml:"MPD"` Period struct { AdaptationSet struct { Representation struct { SegmentTemplate struct { Initialization string `xml:"initialization,attr"` Media string `xml:"media,attr"` Timeline struct { Segments []struct { Duration int `xml:"d,attr"` Repeat int `xml:"r,attr"` } `xml:"S"` } `xml:"SegmentTimeline"` } `xml:"SegmentTemplate"` } `xml:"Representation"` } `xml:"AdaptationSet"` } `xml:"Period"` } // parseManifest extracts download URL from base64 encoded manifest // Supports both BTS (JSON) and DASH (XML) formats // Returns: directURL (for BTS), or initURL + mediaURLs (for DASH) func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { // Decode base64 manifest manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) if err != nil { return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) } manifestStr := string(manifestBytes) // Check if it's BTS format (JSON) or DASH format (XML) if strings.HasPrefix(manifestStr, "{") { // BTS format - JSON with direct URLs var btsManifest TidalBTSManifest if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) } if len(btsManifest.URLs) == 0 { return "", "", nil, fmt.Errorf("no URLs in BTS manifest") } fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs) return btsManifest.URLs[0], "", nil, nil } // DASH format - XML with segments fmt.Println("Manifest: DASH format") // Parse XML var mpd MPD if err := xml.Unmarshal(manifestBytes, &mpd); err != nil { return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err) } segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate initURL = segTemplate.Initialization mediaTemplate := segTemplate.Media if initURL == "" || mediaTemplate == "" { // Fallback: try regex extraction initRe := regexp.MustCompile(`initialization="([^"]+)"`) mediaRe := regexp.MustCompile(`media="([^"]+)"`) if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 { initURL = match[1] } if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 { mediaTemplate = match[1] } } if initURL == "" { return "", "", nil, fmt.Errorf("no initialization URL found in manifest") } // Unescape HTML entities in URLs initURL = strings.ReplaceAll(initURL, "&", "&") mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&") // Calculate segment count from timeline segmentCount := 0 for _, seg := range segTemplate.Timeline.Segments { segmentCount += seg.Repeat + 1 } // If no segments found via XML, try regex if segmentCount == 0 { segRe := regexp.MustCompile(` 1 && match[1] != "" { fmt.Sscanf(match[1], "%d", &repeat) } segmentCount += repeat + 1 } } // Generate media URLs for each segment for i := 1; i <= segmentCount; i++ { mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) mediaURLs = append(mediaURLs, mediaURL) } return "", initURL, mediaURLs, nil } // manifestResult holds the result from a parallel API request for v2 API type manifestResult struct { apiURL string manifest string err error } // getDownloadURLParallel requests download URL from all APIs in parallel // Returns the first successful result (supports both v1 and v2 API formats) func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { if len(apis) == 0 { return "", "", fmt.Errorf("no APIs available") } resultChan := make(chan manifestResult, len(apis)) // Start all requests in parallel with longer timeout client fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis)) for _, apiURL := range apis { go func(api string) { // Create client with longer timeout for parallel requests client := &http.Client{ Timeout: 15 * time.Second, // Longer timeout for parallel } url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) resp, err := client.Get(url) if err != nil { resultChan <- manifestResult{apiURL: api, err: err} return } defer resp.Body.Close() if resp.StatusCode != 200 { resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)} return } // Read body to try both formats body, err := io.ReadAll(resp.Body) if err != nil { resultChan <- manifestResult{apiURL: api, err: err} return } // Try v2 format first (object with manifest) var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { resultChan <- manifestResult{apiURL: api, manifest: v2Response.Data.Manifest, err: nil} return } // Fallback to v1 format (array with OriginalTrackUrl) var v1Responses []TidalAPIResponse if err := json.Unmarshal(body, &v1Responses); err == nil { for _, item := range v1Responses { if item.OriginalTrackURL != "" { // For v1, we store the URL directly with a prefix to distinguish resultChan <- manifestResult{apiURL: api, manifest: "DIRECT:" + item.OriginalTrackURL, err: nil} return } } } resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response")} }(apiURL) } // Collect results - return first success var lastError error var errors []string for i := 0; i < len(apis); i++ { result := <-resultChan if result.err == nil && result.manifest != "" { // First success - use this one fmt.Printf("✓ Got response from: %s\n", result.apiURL) // Check if it's a direct URL (v1) or manifest (v2) if strings.HasPrefix(result.manifest, "DIRECT:") { return result.apiURL, strings.TrimPrefix(result.manifest, "DIRECT:"), nil } // It's a v2 manifest - return it with MANIFEST: prefix return result.apiURL, "MANIFEST:" + result.manifest, nil } else { errMsg := result.err.Error() if len(errMsg) > 50 { errMsg = errMsg[:50] + "..." } errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) lastError = result.err } } // Print all errors for debugging fmt.Println("All APIs failed:") for _, e := range errors { fmt.Printf(" ✗ %s\n", e) } return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) } // DownloadBySearchWithFallback tries multiple APIs when downloading via search // Search is done ONCE, then requests all APIs in PARALLEL for download URL func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) } if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) } } // Search ONCE to find the track fmt.Println("Searching for track...") trackInfo, err := t.SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC, expectedDuration) if err != nil { return "", fmt.Errorf("search failed: %w", err) } if trackInfo.ID == 0 { return "", fmt.Errorf("no track ID found from search") } fmt.Printf("Track found: %s - %s (ID: %d)\n", trackInfo.Artist.Name, trackInfo.Title, trackInfo.ID) // All metadata from Spotify - no fallback to Tidal finalArtistName := artistName finalTrackTitle := trackName finalAlbumTitle := albumName // Sanitize for filename only (not for metadata) finalArtistNameForFile := sanitizeFilename(finalArtistName) finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) // Check if file already exists (use Spotify ISRC) if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists { fmt.Printf("File with ISRC %s already exists: %s\n", spotifyISRC, existingFile) return "EXISTS:" + existingFile, nil } filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) return "EXISTS:" + outputFilename, nil } // Request download URL from ALL APIs in parallel - use first success successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) if err != nil { return "", err } // Download the file using the successful API fmt.Printf("Downloading to: %s\n", outputFilename) downloader := NewTidalDownloader(successAPI) if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { return "", fmt.Errorf("download failed: %w", err) } // Success! Add metadata fmt.Println("Adding metadata...") coverPath := "" // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify if spotifyCoverURL != "" { coverPath = outputFilename + ".cover.jpg" coverClient := NewCoverClient() if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) coverPath = "" } else { defer os.Remove(coverPath) fmt.Println("Spotify cover downloaded") } } // Determine track number to embed - ALL from Spotify trackNumberToEmbed := spotifyTrackNumber if position > 0 && !useAlbumTrackNumber { trackNumberToEmbed = position // Use playlist position } // ALL metadata from Spotify metadata := Metadata{ Title: finalTrackTitle, Artist: finalArtistName, Album: finalAlbumTitle, AlbumArtist: albumArtist, Date: releaseDate, // Recorded date (full date YYYY-MM-DD) TrackNumber: trackNumberToEmbed, TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify DiscNumber: spotifyDiscNumber, // Disc number from Spotify ISRC: spotifyISRC, // ISRC from Spotify Description: "https://github.com/afkarxyz/SpotiFLAC", } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { fmt.Printf("Tagging failed: %v\n", err) } else { fmt.Println("Metadata saved") } fmt.Println("Done") fmt.Println("✓ Downloaded successfully from Tidal (via search)") return outputFilename, nil } func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) { // Get Tidal URL once tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { // Songlink failed to find Tidal URL, try search fallback with all APIs fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) fmt.Println("Trying Tidal search fallback with all APIs...") return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks) } // Use parallel API requests via DownloadByURLWithFallback return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) } // 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, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, expectedDuration int, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) { // Get Tidal URL once tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { // Songlink failed to find Tidal URL, try search fallback with ISRC matching fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) fmt.Println("Trying Tidal search fallback with ISRC matching...") return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks) } // Use parallel API requests via DownloadByURLWithFallback return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) } func buildTidalFilename(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" }