Fallback Tidal with search and ISRC matching
This commit is contained in:
+778
-45
@@ -22,6 +22,13 @@ type TidalDownloader struct {
|
||||
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"`
|
||||
@@ -65,9 +72,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
if apiURL == "" {
|
||||
downloader := &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Timeout: 5 * time.Second, // Fast timeout for quick API fallback
|
||||
},
|
||||
timeout: 30 * time.Second,
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
clientID: string(clientID),
|
||||
clientSecret: string(clientSecret),
|
||||
@@ -83,9 +90,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
|
||||
return &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Timeout: 5 * time.Second, // Fast timeout for quick API fallback
|
||||
},
|
||||
timeout: 30 * time.Second,
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
clientID: string(clientID),
|
||||
clientSecret: string(clientSecret),
|
||||
@@ -168,6 +175,237 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
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==")
|
||||
@@ -507,72 +745,567 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
}
|
||||
|
||||
var lastError error
|
||||
for i, apiURL := range apis {
|
||||
fmt.Printf("[Tidal API %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
|
||||
|
||||
fallbackDownloader := NewTidalDownloader(apiURL)
|
||||
|
||||
result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||
return result, nil
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("directory error: %w", err)
|
||||
}
|
||||
|
||||
lastError = err
|
||||
errMsg := err.Error()
|
||||
if len(errMsg) > 80 {
|
||||
errMsg = errMsg[:80]
|
||||
}
|
||||
fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all %d Tidal APIs failed. Last error: %v", len(apis), lastError)
|
||||
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")
|
||||
}
|
||||
|
||||
// Use Spotify metadata if provided, otherwise fallback to Tidal metadata
|
||||
artistName := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
if artistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
artistName = "Unknown Artist"
|
||||
if len(artists) > 0 {
|
||||
artistName = strings.Join(artists, ", ")
|
||||
}
|
||||
}
|
||||
artistName = sanitizeFilename(artistName)
|
||||
|
||||
if trackTitle == "" {
|
||||
trackTitle = trackInfo.Title
|
||||
if trackTitle == "" {
|
||||
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
trackTitle = sanitizeFilename(trackTitle)
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = trackInfo.Album.Title
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 := ""
|
||||
if trackInfo.Album.Cover != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
albumArt, err := downloader.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||
} else {
|
||||
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Album art downloaded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
releaseYear := ""
|
||||
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||
}
|
||||
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
|
||||
trackNumberToEmbed = trackInfo.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artistName,
|
||||
Album: albumTitle,
|
||||
Date: releaseYear,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
ISRC: trackInfo.ISRC,
|
||||
}
|
||||
|
||||
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 string, useAlbumTrackNumber bool) (string, error) {
|
||||
// Get Tidal URL from Spotify track ID
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// 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, "", 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
// 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) {
|
||||
// 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, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
// 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, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) {
|
||||
return t.DownloadBySearchWithISRC(trackName, artistName, albumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
// DownloadBySearchWithISRC downloads a track by searching Tidal with ISRC matching
|
||||
func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (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")
|
||||
}
|
||||
|
||||
// Use provided metadata, fallback to Tidal metadata
|
||||
finalArtistName := artistName
|
||||
finalTrackTitle := trackName
|
||||
finalAlbumTitle := albumName
|
||||
|
||||
if finalArtistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
if len(artists) > 0 {
|
||||
finalArtistName = strings.Join(artists, ", ")
|
||||
} else {
|
||||
finalArtistName = "Unknown Artist"
|
||||
}
|
||||
}
|
||||
finalArtistName = sanitizeFilename(finalArtistName)
|
||||
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = trackInfo.Title
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
finalTrackTitle = sanitizeFilename(finalTrackTitle)
|
||||
|
||||
if finalAlbumTitle == "" {
|
||||
finalAlbumTitle = trackInfo.Album.Title
|
||||
}
|
||||
|
||||
// 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
|
||||
filename := buildTidalFilename(finalTrackTitle, finalArtistName, 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
|
||||
}
|
||||
|
||||
// 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 := ""
|
||||
if trackInfo.Album.Cover != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
albumArt, err := t.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||
} else {
|
||||
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Album art downloaded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
releaseYear := ""
|
||||
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||
}
|
||||
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
|
||||
trackNumberToEmbed = trackInfo.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: finalTrackTitle,
|
||||
Artist: finalArtistName,
|
||||
Album: finalAlbumTitle,
|
||||
Date: releaseYear,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
ISRC: trackInfo.ISRC,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// apiResult holds the result from a parallel API request
|
||||
type apiResult struct {
|
||||
apiURL string
|
||||
downloadURL string
|
||||
err error
|
||||
}
|
||||
|
||||
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// Returns the first successful result
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
resultChan := make(chan apiResult, 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 <- apiResult{apiURL: api, err: err}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- apiResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)}
|
||||
return
|
||||
}
|
||||
|
||||
var apiResponses []TidalAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
|
||||
resultChan <- apiResult{apiURL: api, err: err}
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range apiResponses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
resultChan <- apiResult{apiURL: api, downloadURL: item.OriginalTrackURL, err: nil}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resultChan <- apiResult{apiURL: api, err: fmt.Errorf("no download URL 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.downloadURL != "" {
|
||||
// First success - use this one
|
||||
fmt.Printf("✓ Got download URL from: %s\n", result.apiURL)
|
||||
return result.apiURL, result.downloadURL, 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, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) {
|
||||
apis, err := t.GetAvailableAPIs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
}
|
||||
|
||||
// Get Tidal URL once
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
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)
|
||||
|
||||
// Prepare metadata
|
||||
finalArtistName := artistName
|
||||
finalTrackTitle := trackName
|
||||
finalAlbumTitle := albumName
|
||||
|
||||
if finalArtistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
if len(artists) > 0 {
|
||||
finalArtistName = strings.Join(artists, ", ")
|
||||
} else {
|
||||
finalArtistName = "Unknown Artist"
|
||||
}
|
||||
}
|
||||
finalArtistName = sanitizeFilename(finalArtistName)
|
||||
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = trackInfo.Title
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
finalTrackTitle = sanitizeFilename(finalTrackTitle)
|
||||
|
||||
if finalAlbumTitle == "" {
|
||||
finalAlbumTitle = trackInfo.Album.Title
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
|
||||
var lastError error
|
||||
for i, apiURL := range apis {
|
||||
fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
|
||||
|
||||
fallbackDownloader := NewTidalDownloader(apiURL)
|
||||
|
||||
result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
lastError = err
|
||||
errMsg := err.Error()
|
||||
if len(errMsg) > 80 {
|
||||
errMsg = errMsg[:80]
|
||||
}
|
||||
fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg)
|
||||
// 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)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
|
||||
// Success! Add metadata
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if trackInfo.Album.Cover != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
albumArt, err := downloader.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||
} else {
|
||||
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Album art downloaded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
releaseYear := ""
|
||||
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||
}
|
||||
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
|
||||
trackNumberToEmbed = trackInfo.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: finalTrackTitle,
|
||||
Artist: finalArtistName,
|
||||
Album: finalAlbumTitle,
|
||||
Date: releaseYear,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
ISRC: trackInfo.ISRC,
|
||||
}
|
||||
|
||||
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 string, useAlbumTrackNumber bool) (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, "", 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
// Use parallel API requests via DownloadByURLWithFallback
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, 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) {
|
||||
// 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, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
// Use parallel API requests via DownloadByURLWithFallback
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
|
||||
Reference in New Issue
Block a user