Fallback Tidal with search and ISRC matching
This commit is contained in:
@@ -52,6 +52,7 @@ type DownloadRequest struct {
|
|||||||
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
|
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
|
||||||
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
||||||
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
||||||
|
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the response structure for download operations
|
// DownloadResponse represents the response structure for download operations
|
||||||
@@ -201,7 +202,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
Error: "Spotify ID is required for Tidal",
|
Error: "Spotify ID is required for Tidal",
|
||||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||||
}
|
}
|
||||||
filename, err = downloader.DownloadWithFallback(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
// Use ISRC matching for search fallback
|
||||||
|
filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber, req.Duration)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||||
@@ -215,7 +217,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
Error: "Spotify ID is required for Tidal",
|
Error: "Spotify ID is required for Tidal",
|
||||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||||
}
|
}
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
// Use ISRC matching for search fallback
|
||||||
|
filename, err = downloader.DownloadWithISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber, req.Duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hiragana to Romaji mapping
|
||||||
|
var hiraganaToRomaji = map[rune]string{
|
||||||
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
|
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||||
|
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||||
|
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||||
|
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||||
|
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||||
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
|
'っ': "", // Double consonant marker
|
||||||
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Katakana to Romaji mapping
|
||||||
|
var katakanaToRomaji = map[rune]string{
|
||||||
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
|
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||||
|
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||||
|
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||||
|
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||||
|
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||||
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
|
'ッ': "", // Double consonant marker
|
||||||
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
|
// Extended katakana
|
||||||
|
'ー': "", // Long vowel mark
|
||||||
|
'ヴ': "vu",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combination mappings for きゃ, しゃ, etc.
|
||||||
|
var combinationHiragana = map[string]string{
|
||||||
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
|
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||||
|
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||||
|
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||||
|
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||||
|
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||||
|
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||||
|
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||||
|
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||||
|
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinationKatakana = map[string]string{
|
||||||
|
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||||
|
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||||
|
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||||
|
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||||
|
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||||
|
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||||
|
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||||
|
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||||
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
|
// Extended combinations
|
||||||
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsJapanese checks if a string contains Japanese characters
|
||||||
|
func ContainsJapanese(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHiragana(r rune) bool {
|
||||||
|
return r >= 0x3040 && r <= 0x309F
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKatakana(r rune) bool {
|
||||||
|
return r >= 0x30A0 && r <= 0x30FF
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKanji(r rune) bool {
|
||||||
|
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
|
}
|
||||||
|
|
||||||
|
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||||
|
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||||
|
func JapaneseToRomaji(text string) string {
|
||||||
|
if !ContainsJapanese(text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
runes := []rune(text)
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(runes) {
|
||||||
|
// Check for っ/ッ (double consonant)
|
||||||
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
|
nextRomaji := ""
|
||||||
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
}
|
||||||
|
if len(nextRomaji) > 0 {
|
||||||
|
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for two-character combinations
|
||||||
|
if i < len(runes)-1 {
|
||||||
|
combo := string(runes[i : i+2])
|
||||||
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if romaji, ok := combinationKatakana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single character conversion
|
||||||
|
r := runes[i]
|
||||||
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if isKanji(r) {
|
||||||
|
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
// Keep other characters (punctuation, spaces, etc.)
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSearchQuery creates a search query from track name and artist
|
||||||
|
// Converts Japanese to romaji if present
|
||||||
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
|
// Convert Japanese to romaji
|
||||||
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean up the query - remove special characters that might interfere with search
|
||||||
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSearchQuery removes special characters that might interfere with search
|
||||||
|
func cleanSearchQuery(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||||
|
// This is useful for creating search queries that work better with Tidal's search
|
||||||
|
func cleanToASCII(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == ',' || r == '.' {
|
||||||
|
// Convert punctuation to space
|
||||||
|
result.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up multiple spaces
|
||||||
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
+778
-45
@@ -22,6 +22,13 @@ type TidalDownloader struct {
|
|||||||
apiURL 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 {
|
type TidalTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -65,9 +72,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
if apiURL == "" {
|
if apiURL == "" {
|
||||||
downloader := &TidalDownloader{
|
downloader := &TidalDownloader{
|
||||||
client: &http.Client{
|
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,
|
maxRetries: 3,
|
||||||
clientID: string(clientID),
|
clientID: string(clientID),
|
||||||
clientSecret: string(clientSecret),
|
clientSecret: string(clientSecret),
|
||||||
@@ -83,9 +90,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
|
|
||||||
return &TidalDownloader{
|
return &TidalDownloader{
|
||||||
client: &http.Client{
|
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,
|
maxRetries: 3,
|
||||||
clientID: string(clientID),
|
clientID: string(clientID),
|
||||||
clientSecret: string(clientSecret),
|
clientSecret: string(clientSecret),
|
||||||
@@ -168,6 +175,237 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
return result.AccessToken, nil
|
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) {
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
// Decode base64 API URL
|
// Decode base64 API URL
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
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)
|
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastError error
|
if outputDir != "." {
|
||||||
for i, apiURL := range apis {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
fmt.Printf("[Tidal API %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
|
return "", fmt.Errorf("directory error: %w", err)
|
||||||
|
|
||||||
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)
|
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) {
|
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
|
// Get Tidal URL from Spotify track ID
|
||||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
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)
|
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()
|
apis, err := t.GetAvailableAPIs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Tidal URL once
|
if outputDir != "." {
|
||||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastError error
|
// Download the file using the successful API
|
||||||
for i, apiURL := range apis {
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
|
downloader := NewTidalDownloader(successAPI)
|
||||||
|
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||||
fallbackDownloader := NewTidalDownloader(apiURL)
|
return "", fmt.Errorf("download failed: %w", err)
|
||||||
|
|
||||||
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)
|
// 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 {
|
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
FileAudio,
|
FileAudio,
|
||||||
Clock
|
Clock,
|
||||||
|
Gauge,
|
||||||
|
HardDrive
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
|
|
||||||
@@ -75,6 +77,21 @@ export function AudioAnalysis({
|
|||||||
return num.toFixed(2);
|
return num.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate Nyquist frequency (half of sample rate)
|
||||||
|
const nyquistFreq = result.sample_rate / 2;
|
||||||
|
|
||||||
|
// Calculate approximate data size (uncompressed PCM)
|
||||||
|
// Formula: sample_rate * channels * (bits_per_sample / 8) * duration
|
||||||
|
const dataSizeBytes = result.sample_rate * result.channels * (result.bits_per_sample / 8) * result.duration;
|
||||||
|
const dataSizeMB = dataSizeBytes / (1024 * 1024);
|
||||||
|
|
||||||
|
const formatDataSize = (mb: number) => {
|
||||||
|
if (mb >= 1024) {
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
return `${mb.toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -124,6 +141,22 @@ export function AudioAnalysis({
|
|||||||
</div>
|
</div>
|
||||||
<p className="font-semibold">{formatDuration(result.duration)}</p>
|
<p className="font-semibold">{formatDuration(result.duration)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Gauge className="h-3 w-3" />
|
||||||
|
Nyquist Frequency
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<HardDrive className="h-3 w-3" />
|
||||||
|
Data Size
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold">{formatDataSize(dataSizeMB)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Range Analysis */}
|
{/* Dynamic Range Analysis */}
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ export function SpectrumVisualization({
|
|||||||
const height = canvas.height;
|
const height = canvas.height;
|
||||||
|
|
||||||
// Calculate margins for labels
|
// Calculate margins for labels
|
||||||
const marginLeft = 80;
|
const marginLeft = 70; // More space for Frequency label
|
||||||
const marginRight = 80;
|
const marginRight = 70; // Space for color bar
|
||||||
const marginTop = 20;
|
const marginTop = 30; // More space at top
|
||||||
const marginBottom = 50;
|
const marginBottom = 65; // More space at bottom for Time label
|
||||||
|
|
||||||
const plotWidth = width - marginLeft - marginRight;
|
const plotWidth = width - marginLeft - marginRight;
|
||||||
const plotHeight = height - marginTop - marginBottom;
|
const plotHeight = height - marginTop - marginBottom;
|
||||||
|
|
||||||
// Black background like Spek
|
// Black background
|
||||||
ctx.fillStyle = "#000000";
|
ctx.fillStyle = "#000000";
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
@@ -51,9 +51,11 @@ export function SpectrumVisualization({
|
|||||||
plotHeight,
|
plotHeight,
|
||||||
spectrumData
|
spectrumData
|
||||||
);
|
);
|
||||||
|
|
||||||
drawGrid(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw axes, labels, and color bar
|
||||||
|
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||||
|
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||||
|
|
||||||
const drawRealSpectrum = (
|
const drawRealSpectrum = (
|
||||||
@@ -70,40 +72,45 @@ export function SpectrumVisualization({
|
|||||||
const freqBins = timeSlices[0].magnitudes.length;
|
const freqBins = timeSlices[0].magnitudes.length;
|
||||||
const nyquistFreq = spectrum.max_freq;
|
const nyquistFreq = spectrum.max_freq;
|
||||||
|
|
||||||
|
// Find min/max dB values
|
||||||
let minDB = 0;
|
let minDB = 0;
|
||||||
let maxDB = -120;
|
let maxDB = -200;
|
||||||
|
|
||||||
timeSlices.forEach((slice) => {
|
timeSlices.forEach((slice) => {
|
||||||
slice.magnitudes.forEach((db) => {
|
slice.magnitudes.forEach((db) => {
|
||||||
if (db > maxDB) maxDB = db;
|
if (db > maxDB) maxDB = db;
|
||||||
if (db < minDB) minDB = db;
|
if (db < minDB && db > -200) minDB = db;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clamp range for better visualization
|
||||||
|
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
|
||||||
const dbRange = maxDB - minDB;
|
const dbRange = maxDB - minDB;
|
||||||
|
|
||||||
|
const sliceWidth = Math.ceil(width / timeSlices.length);
|
||||||
|
|
||||||
for (let t = 0; t < timeSlices.length; t++) {
|
for (let t = 0; t < timeSlices.length; t++) {
|
||||||
const slice = timeSlices[t];
|
const slice = timeSlices[t];
|
||||||
const xPos = x + (t / timeSlices.length) * width;
|
const xPos = x + (t / timeSlices.length) * width;
|
||||||
const sliceWidth = Math.max(1, width / timeSlices.length);
|
|
||||||
|
|
||||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||||
const db = slice.magnitudes[f];
|
const db = slice.magnitudes[f];
|
||||||
|
|
||||||
// Linear frequency scale like Spek
|
// Linear frequency scale
|
||||||
const freq = (f / freqBins) * nyquistFreq;
|
const freq = (f / freqBins) * nyquistFreq;
|
||||||
const freqRatio = freq / nyquistFreq;
|
const freqRatio = freq / nyquistFreq;
|
||||||
|
|
||||||
const yPos = y + height - (freqRatio * height);
|
const yPos = y + height - (freqRatio * height);
|
||||||
|
|
||||||
// Calculate next frequency bin position
|
// Calculate bin height
|
||||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||||
const nextYPos = y + height - (nextFreqRatio * height);
|
const nextYPos = y + height - (nextFreqRatio * height);
|
||||||
|
|
||||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||||
|
|
||||||
const intensity = (db - minDB) / dbRange;
|
// Normalize intensity
|
||||||
|
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||||
|
|
||||||
const color = getSpekColor(intensity);
|
const color = getSpekColor(intensity);
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
@@ -112,161 +119,168 @@ export function SpectrumVisualization({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Vibrant color scheme like Spek - NGEJERENG!
|
||||||
const getSpekColor = (intensity: number): string => {
|
const getSpekColor = (intensity: number): string => {
|
||||||
// Enhanced color scheme - better than Spek
|
if (intensity < 0.08) {
|
||||||
if (intensity < 0.10) {
|
// Black to deep blue
|
||||||
// Deep black to dark blue
|
const t = intensity / 0.08;
|
||||||
const t = intensity / 0.10;
|
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||||
return `rgb(0, 0, ${Math.floor(t * 100)})`;
|
} else if (intensity < 0.18) {
|
||||||
} else if (intensity < 0.25) {
|
// Deep blue to bright blue
|
||||||
// Dark blue to bright blue
|
const t = (intensity - 0.08) / 0.10;
|
||||||
const t = (intensity - 0.10) / 0.15;
|
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
||||||
return `rgb(0, ${Math.floor(t * 50)}, ${Math.floor(100 + t * 155)})`;
|
} else if (intensity < 0.28) {
|
||||||
|
// Blue to magenta/purple
|
||||||
|
const t = (intensity - 0.18) / 0.10;
|
||||||
|
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
||||||
} else if (intensity < 0.40) {
|
} else if (intensity < 0.40) {
|
||||||
// Blue to cyan
|
// Magenta to bright red
|
||||||
const t = (intensity - 0.25) / 0.15;
|
const t = (intensity - 0.28) / 0.12;
|
||||||
return `rgb(0, ${Math.floor(50 + t * 205)}, 255)`;
|
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
||||||
} else if (intensity < 0.55) {
|
} else if (intensity < 0.52) {
|
||||||
// Cyan to green
|
// Red to orange-red
|
||||||
const t = (intensity - 0.40) / 0.15;
|
const t = (intensity - 0.40) / 0.12;
|
||||||
return `rgb(0, 255, ${Math.floor(255 - t * 200)})`;
|
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||||
} else if (intensity < 0.70) {
|
} else if (intensity < 0.65) {
|
||||||
// Green to yellow
|
// Orange-red to bright orange
|
||||||
const t = (intensity - 0.55) / 0.15;
|
const t = (intensity - 0.52) / 0.13;
|
||||||
return `rgb(${Math.floor(t * 255)}, 255, ${Math.floor(55 - t * 55)})`;
|
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||||
} else if (intensity < 0.85) {
|
} else if (intensity < 0.78) {
|
||||||
// Yellow to orange
|
// Orange to yellow-orange
|
||||||
const t = (intensity - 0.70) / 0.15;
|
const t = (intensity - 0.65) / 0.13;
|
||||||
return `rgb(255, ${Math.floor(255 - t * 100)}, 0)`;
|
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||||
|
} else if (intensity < 0.90) {
|
||||||
|
// Yellow-orange to bright yellow
|
||||||
|
const t = (intensity - 0.78) / 0.12;
|
||||||
|
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||||
} else {
|
} else {
|
||||||
// Orange to red
|
// Yellow to white (hottest)
|
||||||
const t = (intensity - 0.85) / 0.15;
|
const t = (intensity - 0.90) / 0.10;
|
||||||
return `rgb(255, ${Math.floor(155 - t * 155)}, ${Math.floor(t * 30)})`;
|
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawGrid = (
|
const drawAxesAndLabels = (
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
nyquistFreq: number
|
nyquistFreq: number,
|
||||||
|
duration: number,
|
||||||
|
sampleRate: number
|
||||||
) => {
|
) => {
|
||||||
// Enhanced grid lines
|
// Frequency labels on Y-axis
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
|
ctx.fillStyle = "#CCCCCC";
|
||||||
ctx.lineWidth = 1;
|
ctx.font = "12px Arial";
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
// Dynamic frequency grid lines based on Nyquist frequency
|
// Generate frequency labels based on Nyquist
|
||||||
const generateFreqLines = (maxFreq: number): number[] => {
|
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||||
if (maxFreq <= 24000) {
|
|
||||||
// Standard 44.1/48 kHz (Nyquist ~22/24 kHz)
|
|
||||||
return [1000, 2000, 5000, 10000, 15000, 20000];
|
|
||||||
} else if (maxFreq <= 48000) {
|
|
||||||
// 88.2/96 kHz (Nyquist ~44/48 kHz)
|
|
||||||
return [5000, 10000, 20000, 30000, 40000];
|
|
||||||
} else if (maxFreq <= 96000) {
|
|
||||||
// 176.4/192 kHz (Nyquist ~88/96 kHz)
|
|
||||||
return [10000, 20000, 40000, 60000, 80000];
|
|
||||||
} else {
|
|
||||||
// 352.8/384 kHz and higher (Nyquist ~176/192+ kHz)
|
|
||||||
return [20000, 40000, 80000, 120000, 160000];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const freqLines = generateFreqLines(nyquistFreq);
|
freqLabels.forEach(freq => {
|
||||||
|
|
||||||
freqLines.forEach(freq => {
|
|
||||||
if (freq <= nyquistFreq) {
|
|
||||||
const freqRatio = freq / nyquistFreq;
|
|
||||||
const yPos = y + height - (freqRatio * height);
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, yPos);
|
|
||||||
ctx.lineTo(x + width, yPos);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Vertical time grid lines
|
|
||||||
for (let i = 1; i < 10; i++) {
|
|
||||||
const xPos = x + (i / 10) * width;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(xPos, y);
|
|
||||||
ctx.lineTo(xPos, y + height);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillStyle = "rgba(220, 220, 220, 0.9)";
|
|
||||||
ctx.font = "11px Arial";
|
|
||||||
|
|
||||||
// Frequency labels - dynamic formatting
|
|
||||||
freqLines.forEach(freq => {
|
|
||||||
if (freq <= nyquistFreq) {
|
if (freq <= nyquistFreq) {
|
||||||
const freqRatio = freq / nyquistFreq;
|
const freqRatio = freq / nyquistFreq;
|
||||||
const yPos = y + height - (freqRatio * height);
|
const yPos = y + height - (freqRatio * height);
|
||||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||||
|
ctx.fillText(label, x - 8, yPos);
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
ctx.fillText(label, x - 6, yPos);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Time labels
|
// "0" at bottom
|
||||||
|
ctx.fillText("0", x - 8, y + height);
|
||||||
|
|
||||||
|
// Time labels on X-axis
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "top";
|
ctx.textBaseline = "top";
|
||||||
for (let i = 0; i <= 10; i++) {
|
|
||||||
const timePos = x + (i / 10) * width;
|
const timeStep = getTimeStep(duration);
|
||||||
const timeValue = (i / 10) * duration;
|
for (let t = 0; t <= duration; t += timeStep) {
|
||||||
if (i % 2 === 0) {
|
const xPos = x + (t / duration) * width;
|
||||||
ctx.fillText(timeValue.toFixed(1), timePos, y + height + 5);
|
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Axis titles
|
||||||
ctx.fillStyle = "#FFFFFF";
|
ctx.fillStyle = "#FFFFFF";
|
||||||
ctx.font = "bold 13px Arial";
|
ctx.font = "13px Arial";
|
||||||
ctx.shadowColor = "rgba(0, 0, 0, 0.8)";
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
|
// Y-axis title: "Frequency (Hz)"
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(8, y + height / 2);
|
ctx.translate(12, y + height / 2);
|
||||||
ctx.rotate(-Math.PI / 2);
|
ctx.rotate(-Math.PI / 2);
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.fillText("Frequency (kHz)", 0, 0);
|
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
|
// X-axis title: "Time (seconds)"
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.fillText("Time (s)", x + width / 2, y + height + 26);
|
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
const boxGradient = ctx.createLinearGradient(x + width - 200, y + 5, x + width - 200, y + 68);
|
// Sample rate info in top right
|
||||||
boxGradient.addColorStop(0, "rgba(0, 0, 0, 0.85)");
|
ctx.textAlign = "right";
|
||||||
boxGradient.addColorStop(1, "rgba(0, 0, 0, 0.7)");
|
ctx.fillStyle = "#CCCCCC";
|
||||||
ctx.fillStyle = boxGradient;
|
ctx.font = "12px Arial";
|
||||||
ctx.fillRect(x + width - 200, y + 5, 190, 63);
|
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
||||||
|
};
|
||||||
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
|
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
||||||
ctx.lineWidth = 1.5;
|
if (nyquistFreq <= 24000) {
|
||||||
ctx.strokeRect(x + width - 200, y + 5, 190, 63);
|
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
||||||
|
} else if (nyquistFreq <= 48000) {
|
||||||
|
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
||||||
|
} else if (nyquistFreq <= 96000) {
|
||||||
|
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
||||||
|
} else {
|
||||||
|
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeStep = (duration: number): number => {
|
||||||
|
// Always use 30s intervals like the reference image
|
||||||
|
if (duration <= 60) return 15;
|
||||||
|
if (duration <= 120) return 30;
|
||||||
|
if (duration <= 300) return 30;
|
||||||
|
if (duration <= 600) return 60;
|
||||||
|
return 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawColorBar = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) => {
|
||||||
|
// Draw gradient color bar
|
||||||
|
for (let i = 0; i < height; i++) {
|
||||||
|
const intensity = 1 - (i / height); // Top is high, bottom is low
|
||||||
|
const color = getSpekColor(intensity);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(x, y + i, width, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border around color bar
|
||||||
|
ctx.strokeStyle = "#666666";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(x, y, width, height);
|
||||||
|
|
||||||
|
// Labels
|
||||||
ctx.fillStyle = "#FFFFFF";
|
ctx.fillStyle = "#FFFFFF";
|
||||||
ctx.font = "600 11px Arial";
|
ctx.font = "11px Arial";
|
||||||
ctx.textAlign = "left";
|
ctx.textAlign = "left";
|
||||||
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
|
ctx.textBaseline = "middle";
|
||||||
ctx.shadowBlur = 2;
|
|
||||||
ctx.fillText(`Sample Rate: ${(sampleRate / 1000).toFixed(1)} kHz`, x + width - 190, y + 20);
|
ctx.fillText("High", x + width + 5, y + 10);
|
||||||
ctx.fillText(`Bit Depth: ${bitsPerSample}-bit`, x + width - 190, y + 36);
|
ctx.fillText("Low", x + width + 5, y + height - 10);
|
||||||
ctx.fillText(`Nyquist: ${(nyquistFreq / 1000).toFixed(1)} kHz`, x + width - 190, y + 52);
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={1600}
|
width={1200}
|
||||||
height={800}
|
height={600}
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
style={{ imageRendering: "auto" }}
|
style={{ imageRendering: "auto" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export function useDownload() {
|
|||||||
playlistName?: string,
|
playlistName?: string,
|
||||||
isArtistDiscography?: boolean,
|
isArtistDiscography?: boolean,
|
||||||
position?: number,
|
position?: number,
|
||||||
spotifyId?: string
|
spotifyId?: string,
|
||||||
|
durationMs?: number
|
||||||
) => {
|
) => {
|
||||||
let service = settings.downloader;
|
let service = settings.downloader;
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert duration from ms to seconds for backend
|
||||||
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
|
|
||||||
// Try Tidal first
|
// Try Tidal first
|
||||||
if (streamingURLs?.tidal_url) {
|
if (streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
@@ -90,6 +94,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
service_url: streamingURLs.tidal_url,
|
service_url: streamingURLs.tidal_url,
|
||||||
|
duration: durationSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tidalResponse.success) {
|
if (tidalResponse.success) {
|
||||||
@@ -167,6 +172,9 @@ export function useDownload() {
|
|||||||
service = "qobuz";
|
service = "qobuz";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert duration from ms to seconds for backend (if not already done above)
|
||||||
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
|
|
||||||
return await downloadTrack({
|
return await downloadTrack({
|
||||||
isrc,
|
isrc,
|
||||||
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
||||||
@@ -180,6 +188,7 @@ export function useDownload() {
|
|||||||
position,
|
position,
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
|
duration: durationSecondsForFallback,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,7 +199,8 @@ export function useDownload() {
|
|||||||
albumName?: string,
|
albumName?: string,
|
||||||
spotifyId?: string,
|
spotifyId?: string,
|
||||||
playlistName?: string,
|
playlistName?: string,
|
||||||
isArtistDiscography?: boolean
|
isArtistDiscography?: boolean,
|
||||||
|
durationMs?: number
|
||||||
) => {
|
) => {
|
||||||
if (!isrc) {
|
if (!isrc) {
|
||||||
toast.error("No ISRC found for this track");
|
toast.error("No ISRC found for this track");
|
||||||
@@ -212,7 +222,8 @@ export function useDownload() {
|
|||||||
playlistName,
|
playlistName,
|
||||||
isArtistDiscography,
|
isArtistDiscography,
|
||||||
undefined, // Don't pass position for single track
|
undefined, // Don't pass position for single track
|
||||||
spotifyId
|
spotifyId,
|
||||||
|
durationMs
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -290,7 +301,8 @@ export function useDownload() {
|
|||||||
playlistName,
|
playlistName,
|
||||||
isArtistDiscography,
|
isArtistDiscography,
|
||||||
i + 1, // Sequential position based on selection order
|
i + 1, // Sequential position based on selection order
|
||||||
track?.spotify_id
|
track?.spotify_id,
|
||||||
|
track?.duration_ms
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -394,7 +406,8 @@ export function useDownload() {
|
|||||||
playlistName,
|
playlistName,
|
||||||
isArtistDiscography,
|
isArtistDiscography,
|
||||||
i + 1,
|
i + 1,
|
||||||
track.spotify_id
|
track.spotify_id,
|
||||||
|
track.duration_ms
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export interface DownloadRequest {
|
|||||||
use_album_track_number?: boolean;
|
use_album_track_number?: boolean;
|
||||||
spotify_id?: string;
|
spotify_id?: string;
|
||||||
service_url?: string;
|
service_url?: string;
|
||||||
|
duration?: number; // Track duration in seconds for better matching
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadResponse {
|
export interface DownloadResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user