603 lines
17 KiB
Go
603 lines
17 KiB
Go
package backend
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type TidalDownloader struct {
|
|
client *http.Client
|
|
timeout time.Duration
|
|
maxRetries int
|
|
clientID string
|
|
clientSecret string
|
|
apiURL string
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type TidalAPIInfo struct {
|
|
URL string `json:"url"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
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: 60 * time.Second,
|
|
},
|
|
timeout: 30 * 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] // Use first available API
|
|
}
|
|
}
|
|
|
|
return &TidalDownloader{
|
|
client: &http.Client{
|
|
Timeout: 60 * time.Second,
|
|
},
|
|
timeout: 30 * 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
var apiResponses []TidalAPIResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
|
|
fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err)
|
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
|
|
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (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")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Build filename based on format settings
|
|
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
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
// Use album track number if in album folder structure, otherwise use playlist position
|
|
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) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
|
apis, err := t.GetAvailableAPIs()
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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) {
|
|
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 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)
|
|
}
|
|
|
|
return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
|
|
}
|
|
|
|
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
|
var filename string
|
|
|
|
// Build base filename based on format
|
|
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
|
|
if includeTrackNumber && position > 0 {
|
|
// Use album track number if in album folder structure, otherwise use playlist position
|
|
numberToUse := position
|
|
if useAlbumTrackNumber && trackNumber > 0 {
|
|
numberToUse = trackNumber
|
|
}
|
|
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
|
}
|
|
|
|
return filename + ".flac"
|
|
}
|