diff --git a/app.go b/app.go index 9fe1929..084d807 100644 --- a/app.go +++ b/app.go @@ -52,6 +52,7 @@ type DownloadRequest struct { 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 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 @@ -201,7 +202,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "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 { downloader := backend.NewTidalDownloader(req.ApiURL) @@ -215,7 +217,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "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) } } diff --git a/backend/romaji.go b/backend/romaji.go new file mode 100644 index 0000000..a41b2c8 --- /dev/null +++ b/backend/romaji.go @@ -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) +} diff --git a/backend/tidal.go b/backend/tidal.go index 1bf6246..a3ee2f2 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -22,6 +22,13 @@ type TidalDownloader struct { apiURL string } +type TidalSearchResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalTrack `json:"items"` +} + type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -65,9 +72,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { if apiURL == "" { downloader := &TidalDownloader{ client: &http.Client{ - Timeout: 60 * time.Second, + Timeout: 5 * time.Second, // Fast timeout for quick API fallback }, - timeout: 30 * time.Second, + timeout: 5 * time.Second, maxRetries: 3, clientID: string(clientID), clientSecret: string(clientSecret), @@ -83,9 +90,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { return &TidalDownloader{ client: &http.Client{ - Timeout: 60 * time.Second, + Timeout: 5 * time.Second, // Fast timeout for quick API fallback }, - timeout: 30 * time.Second, + timeout: 5 * time.Second, maxRetries: 3, clientID: string(clientID), clientSecret: string(clientSecret), @@ -168,6 +175,237 @@ func (t *TidalDownloader) GetAccessToken() (string, error) { return result.AccessToken, nil } +// SearchTracks searches for tracks on Tidal with configurable limit +func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) { + return t.SearchTracksWithLimit(query, 50) // Default to 50 results for better matching +} + +// SearchTracksWithLimit searches for tracks on Tidal with a specific limit +func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*TidalSearchResponse, error) { + token, err := t.GetAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + + // Decode base64 API URL and encode the query parameter + searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") + searchURL := fmt.Sprintf("%s%s&limit=%d&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query), limit) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := t.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body)) + } + + var result TidalSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// SearchTrackByMetadata searches for a track using artist name and track name +// It tries multiple search strategies including romaji conversion for Japanese text +// Now accepts ISRC for exact matching +func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string, expectedDuration int) (*TidalTrack, error) { + return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", expectedDuration) +} + +// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority +func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { + // Build search queries - multiple strategies + queries := []string{} + + // Strategy 1: Artist + Track name (original) + if artistName != "" && trackName != "" { + queries = append(queries, artistName+" "+trackName) + } + + // Strategy 2: Track name only (sometimes works better) + if trackName != "" { + queries = append(queries, trackName) + } + + // Strategy 3: Romaji versions if Japanese detected + if ContainsJapanese(trackName) || ContainsJapanese(artistName) { + // Convert to romaji (hiragana/katakana only, kanji stays) + romajiTrack := JapaneseToRomaji(trackName) + romajiArtist := JapaneseToRomaji(artistName) + + // Clean and remove ALL non-ASCII characters (including kanji) + cleanRomajiTrack := cleanToASCII(romajiTrack) + cleanRomajiArtist := cleanToASCII(romajiArtist) + + // Artist + Track romaji (cleaned to ASCII only) + if cleanRomajiArtist != "" && cleanRomajiTrack != "" { + romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack + if !containsQuery(queries, romajiQuery) { + queries = append(queries, romajiQuery) + fmt.Printf("Japanese detected, adding romaji query: %s\n", romajiQuery) + } + } + + // Track romaji only (cleaned) + if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { + if !containsQuery(queries, cleanRomajiTrack) { + queries = append(queries, cleanRomajiTrack) + } + } + + // Also try with partial romaji (artist + cleaned track) + if artistName != "" && cleanRomajiTrack != "" { + partialQuery := artistName + " " + cleanRomajiTrack + if !containsQuery(queries, partialQuery) { + queries = append(queries, partialQuery) + } + } + } + + // Strategy 4: Artist only as last resort + if artistName != "" { + artistOnly := cleanToASCII(JapaneseToRomaji(artistName)) + if artistOnly != "" && !containsQuery(queries, artistOnly) { + queries = append(queries, artistOnly) + } + } + + // Collect all search results from all queries + var allTracks []TidalTrack + searchedQueries := make(map[string]bool) + + for _, query := range queries { + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" || searchedQueries[cleanQuery] { + continue + } + searchedQueries[cleanQuery] = true + + fmt.Printf("Searching Tidal for: %s\n", cleanQuery) + + result, err := t.SearchTracksWithLimit(cleanQuery, 100) // Get more results + if err != nil { + fmt.Printf("Search error for '%s': %v\n", cleanQuery, err) + continue + } + + if len(result.Items) > 0 { + fmt.Printf("Found %d results for '%s'\n", len(result.Items), cleanQuery) + allTracks = append(allTracks, result.Items...) + } + } + + if len(allTracks) == 0 { + return nil, fmt.Errorf("no tracks found for any search query") + } + + // Priority 1: Match by ISRC (exact match) + if spotifyISRC != "" { + fmt.Printf("Looking for ISRC match: %s\n", spotifyISRC) + for i := range allTracks { + track := &allTracks[i] + if track.ISRC == spotifyISRC { + fmt.Printf("✓ ISRC match found: %s - %s (ISRC: %s, Quality: %s)\n", + track.Artist.Name, track.Title, track.ISRC, track.AudioQuality) + return track, nil + } + } + fmt.Printf("No exact ISRC match found, trying other matching methods...\n") + } + + // If ISRC was provided but no match found, return error - don't download wrong track + if spotifyISRC != "" { + fmt.Printf("✗ No ISRC match found for: %s\n", spotifyISRC) + fmt.Printf(" Available ISRCs from search results:\n") + // Show first 5 results for debugging + for i, track := range allTracks { + if i >= 5 { + fmt.Printf(" ... and %d more results\n", len(allTracks)-5) + break + } + fmt.Printf(" - %s - %s (ISRC: %s)\n", track.Artist.Name, track.Title, track.ISRC) + } + return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) + } + + // Only proceed without ISRC matching if no ISRC was provided + // Priority 2: Match by duration (within tolerance) + prefer best quality + var bestMatch *TidalTrack + if expectedDuration > 0 { + tolerance := 3 // 3 seconds tolerance + var durationMatches []*TidalTrack + + for i := range allTracks { + track := &allTracks[i] + durationDiff := track.Duration - expectedDuration + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff <= tolerance { + durationMatches = append(durationMatches, track) + } + } + + if len(durationMatches) > 0 { + // Find best quality among duration matches + bestMatch = durationMatches[0] + for _, track := range durationMatches { + for _, tag := range track.MediaMetadata.Tags { + if tag == "HIRES_LOSSLESS" { + bestMatch = track + break + } + } + } + fmt.Printf("Found via duration match: %s - %s (%s)\n", + bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality) + return bestMatch, nil + } + } + + // Priority 3: Just take the best quality from first results (only when no ISRC provided) + bestMatch = &allTracks[0] + for i := range allTracks { + track := &allTracks[i] + for _, tag := range track.MediaMetadata.Tags { + if tag == "HIRES_LOSSLESS" { + bestMatch = track + break + } + } + if bestMatch != &allTracks[0] { + break // Found HIRES_LOSSLESS + } + } + + fmt.Printf("Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n", + bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality) + + return bestMatch, nil +} + +// containsQuery checks if a query already exists in the list +func containsQuery(queries []string, query string) bool { + for _, q := range queries { + if q == query { + return true + } + } + return false +} + func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { // Decode base64 API URL spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") @@ -507,72 +745,567 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "", fmt.Errorf("no APIs available for fallback: %w", err) } - var lastError error - for i, apiURL := range apis { - fmt.Printf("[Tidal API %d/%d] Trying: %s\n", i+1, len(apis), apiURL) - - fallbackDownloader := NewTidalDownloader(apiURL) - - result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) - if err == nil { - fmt.Printf("✓ Success with: %s\n", apiURL) - return result, nil + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("directory error: %w", err) } - - lastError = err - errMsg := err.Error() - if len(errMsg) > 80 { - errMsg = errMsg[:80] - } - fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg) } - return "", fmt.Errorf("all %d Tidal APIs failed. Last error: %v", len(apis), lastError) + fmt.Printf("Using Tidal URL: %s\n", tidalURL) + + // Extract track ID from URL + trackID, err := t.GetTrackIDFromURL(tidalURL) + if err != nil { + return "", err + } + + // Get track info by ID + trackInfo, err := t.GetTrackInfoByID(trackID) + if err != nil { + return "", err + } + + if trackInfo.ID == 0 { + return "", fmt.Errorf("no track ID found") + } + + // Use Spotify metadata if provided, otherwise fallback to Tidal metadata + artistName := spotifyArtistName + trackTitle := spotifyTrackName + albumTitle := spotifyAlbumName + + if artistName == "" { + var artists []string + if len(trackInfo.Artists) > 0 { + for _, artist := range trackInfo.Artists { + if artist.Name != "" { + artists = append(artists, artist.Name) + } + } + } else if trackInfo.Artist.Name != "" { + artists = append(artists, trackInfo.Artist.Name) + } + artistName = "Unknown Artist" + if len(artists) > 0 { + artistName = strings.Join(artists, ", ") + } + } + artistName = sanitizeFilename(artistName) + + if trackTitle == "" { + trackTitle = trackInfo.Title + if trackTitle == "" { + trackTitle = fmt.Sprintf("track_%d", trackInfo.ID) + } + } + trackTitle = sanitizeFilename(trackTitle) + + if albumTitle == "" { + albumTitle = trackInfo.Album.Title + } + + // Check if file with same ISRC already exists + if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { + fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) + return "EXISTS:" + existingFile, nil + } + + filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + outputFilename := filepath.Join(outputDir, filename) + + if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + return "EXISTS:" + outputFilename, nil + } + + // Request download URL from ALL APIs in parallel - use first success + successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) + if err != nil { + return "", err + } + + // Download the file + fmt.Printf("Downloading to: %s\n", outputFilename) + downloader := NewTidalDownloader(successAPI) + if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { + return "", err + } + + fmt.Println("Adding metadata...") + + coverPath := "" + if trackInfo.Album.Cover != "" { + coverPath = outputFilename + ".cover.jpg" + albumArt, err := downloader.DownloadAlbumArt(trackInfo.Album.Cover) + if err != nil { + fmt.Printf("Warning: Failed to download album art: %v\n", err) + } else { + if err := os.WriteFile(coverPath, albumArt, 0644); err != nil { + fmt.Printf("Warning: Failed to save album art: %v\n", err) + } else { + defer os.Remove(coverPath) + fmt.Println("Album art downloaded") + } + } + } + + releaseYear := "" + if len(trackInfo.Album.ReleaseDate) >= 4 { + releaseYear = trackInfo.Album.ReleaseDate[:4] + } + + trackNumberToEmbed := 0 + if position > 0 { + if useAlbumTrackNumber && trackInfo.TrackNumber > 0 { + trackNumberToEmbed = trackInfo.TrackNumber + } else { + trackNumberToEmbed = position + } + } + + metadata := Metadata{ + Title: trackTitle, + Artist: artistName, + Album: albumTitle, + Date: releaseYear, + TrackNumber: trackNumberToEmbed, + DiscNumber: trackInfo.VolumeNumber, + ISRC: trackInfo.ISRC, + } + + if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { + fmt.Printf("Tagging failed: %v\n", err) + } else { + fmt.Println("Metadata saved") + } + + fmt.Println("Done") + fmt.Println("✓ Downloaded successfully from Tidal") + return outputFilename, nil } func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { // Get Tidal URL from Spotify track ID tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { - return "", err + // Songlink failed to find Tidal URL, try search fallback + fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) + fmt.Println("Trying Tidal search fallback...") + return t.DownloadBySearch(spotifyTrackName, spotifyArtistName, spotifyAlbumName, "", 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) } return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) } -func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +// DownloadWithISRC downloads a track with ISRC matching for search fallback +func (t *TidalDownloader) DownloadWithISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool, expectedDuration int) (string, error) { + // Get Tidal URL from Spotify track ID + tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) + if err != nil { + // Songlink failed to find Tidal URL, try search fallback with ISRC + fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) + fmt.Println("Trying Tidal search fallback with ISRC matching...") + return t.DownloadBySearchWithISRC(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + } + + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) +} + +// DownloadBySearch downloads a track by searching Tidal directly using metadata +// This is used as a fallback when Songlink API doesn't find a Tidal URL +func (t *TidalDownloader) DownloadBySearch(trackName, artistName, albumName, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) { + return t.DownloadBySearchWithISRC(trackName, artistName, albumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) +} + +// DownloadBySearchWithISRC downloads a track by searching Tidal with ISRC matching +func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) { + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("directory error: %w", err) + } + } + + // Search for the track with ISRC matching + trackInfo, err := t.SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC, expectedDuration) + if err != nil { + return "", fmt.Errorf("search fallback failed: %w", err) + } + + if trackInfo.ID == 0 { + return "", fmt.Errorf("no track ID found from search") + } + + // Use provided metadata, fallback to Tidal metadata + finalArtistName := artistName + finalTrackTitle := trackName + finalAlbumTitle := albumName + + if finalArtistName == "" { + var artists []string + if len(trackInfo.Artists) > 0 { + for _, artist := range trackInfo.Artists { + if artist.Name != "" { + artists = append(artists, artist.Name) + } + } + } else if trackInfo.Artist.Name != "" { + artists = append(artists, trackInfo.Artist.Name) + } + if len(artists) > 0 { + finalArtistName = strings.Join(artists, ", ") + } else { + finalArtistName = "Unknown Artist" + } + } + finalArtistName = sanitizeFilename(finalArtistName) + + if finalTrackTitle == "" { + finalTrackTitle = trackInfo.Title + if finalTrackTitle == "" { + finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID) + } + } + finalTrackTitle = sanitizeFilename(finalTrackTitle) + + if finalAlbumTitle == "" { + finalAlbumTitle = trackInfo.Album.Title + } + + // Check if file with same ISRC already exists + if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { + fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) + return "EXISTS:" + existingFile, nil + } + + // Build filename + filename := buildTidalFilename(finalTrackTitle, finalArtistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + outputFilename := filepath.Join(outputDir, filename) + + if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + return "EXISTS:" + outputFilename, nil + } + + // Get download URL + downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality) + if err != nil { + return "", err + } + + fmt.Printf("Downloading to: %s\n", outputFilename) + if err := t.DownloadFile(downloadURL, outputFilename); err != nil { + return "", err + } + + fmt.Println("Adding metadata...") + + coverPath := "" + if trackInfo.Album.Cover != "" { + coverPath = outputFilename + ".cover.jpg" + albumArt, err := t.DownloadAlbumArt(trackInfo.Album.Cover) + if err != nil { + fmt.Printf("Warning: Failed to download album art: %v\n", err) + } else { + if err := os.WriteFile(coverPath, albumArt, 0644); err != nil { + fmt.Printf("Warning: Failed to save album art: %v\n", err) + } else { + defer os.Remove(coverPath) + fmt.Println("Album art downloaded") + } + } + } + + releaseYear := "" + if len(trackInfo.Album.ReleaseDate) >= 4 { + releaseYear = trackInfo.Album.ReleaseDate[:4] + } + + trackNumberToEmbed := 0 + if position > 0 { + if useAlbumTrackNumber && trackInfo.TrackNumber > 0 { + trackNumberToEmbed = trackInfo.TrackNumber + } else { + trackNumberToEmbed = position + } + } + + metadata := Metadata{ + Title: finalTrackTitle, + Artist: finalArtistName, + Album: finalAlbumTitle, + Date: releaseYear, + TrackNumber: trackNumberToEmbed, + DiscNumber: trackInfo.VolumeNumber, + ISRC: trackInfo.ISRC, + } + + if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { + fmt.Printf("Tagging failed: %v\n", err) + } else { + fmt.Println("Metadata saved") + } + + fmt.Println("Done") + fmt.Println("✓ Downloaded successfully from Tidal (via search)") + return outputFilename, nil +} + +// apiResult holds the result from a parallel API request +type apiResult struct { + apiURL string + downloadURL string + err error +} + +// getDownloadURLParallel requests download URL from all APIs in parallel +// Returns the first successful result +func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { + if len(apis) == 0 { + return "", "", fmt.Errorf("no APIs available") + } + + resultChan := make(chan apiResult, len(apis)) + + // Start all requests in parallel with longer timeout client + fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis)) + for _, apiURL := range apis { + go func(api string) { + // Create client with longer timeout for parallel requests + client := &http.Client{ + Timeout: 15 * time.Second, // Longer timeout for parallel + } + + url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) + resp, err := client.Get(url) + if err != nil { + resultChan <- apiResult{apiURL: api, err: err} + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + resultChan <- apiResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)} + return + } + + var apiResponses []TidalAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil { + resultChan <- apiResult{apiURL: api, err: err} + return + } + + for _, item := range apiResponses { + if item.OriginalTrackURL != "" { + resultChan <- apiResult{apiURL: api, downloadURL: item.OriginalTrackURL, err: nil} + return + } + } + + resultChan <- apiResult{apiURL: api, err: fmt.Errorf("no download URL in response")} + }(apiURL) + } + + // Collect results - return first success + var lastError error + var errors []string + + for i := 0; i < len(apis); i++ { + result := <-resultChan + if result.err == nil && result.downloadURL != "" { + // First success - use this one + fmt.Printf("✓ Got download URL from: %s\n", result.apiURL) + return result.apiURL, result.downloadURL, nil + } else { + errMsg := result.err.Error() + if len(errMsg) > 50 { + errMsg = errMsg[:50] + "..." + } + errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) + lastError = result.err + } + } + + // Print all errors for debugging + fmt.Println("All APIs failed:") + for _, e := range errors { + fmt.Printf(" ✗ %s\n", e) + } + + return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) +} + +// DownloadBySearchWithFallback tries multiple APIs when downloading via search +// Search is done ONCE, then requests all APIs in PARALLEL for download URL +func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, albumName, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) } - // Get Tidal URL once - tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("directory error: %w", err) + } + } + + // Search ONCE to find the track + fmt.Println("Searching for track...") + trackInfo, err := t.SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC, expectedDuration) + if err != nil { + return "", fmt.Errorf("search failed: %w", err) + } + + if trackInfo.ID == 0 { + return "", fmt.Errorf("no track ID found from search") + } + + fmt.Printf("Track found: %s - %s (ID: %d)\n", trackInfo.Artist.Name, trackInfo.Title, trackInfo.ID) + + // Prepare metadata + finalArtistName := artistName + finalTrackTitle := trackName + finalAlbumTitle := albumName + + if finalArtistName == "" { + var artists []string + if len(trackInfo.Artists) > 0 { + for _, artist := range trackInfo.Artists { + if artist.Name != "" { + artists = append(artists, artist.Name) + } + } + } else if trackInfo.Artist.Name != "" { + artists = append(artists, trackInfo.Artist.Name) + } + if len(artists) > 0 { + finalArtistName = strings.Join(artists, ", ") + } else { + finalArtistName = "Unknown Artist" + } + } + finalArtistName = sanitizeFilename(finalArtistName) + + if finalTrackTitle == "" { + finalTrackTitle = trackInfo.Title + if finalTrackTitle == "" { + finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID) + } + } + finalTrackTitle = sanitizeFilename(finalTrackTitle) + + if finalAlbumTitle == "" { + finalAlbumTitle = trackInfo.Album.Title + } + + // Check if file already exists + if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { + fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) + return "EXISTS:" + existingFile, nil + } + + filename := buildTidalFilename(finalTrackTitle, finalArtistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + outputFilename := filepath.Join(outputDir, filename) + + if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + return "EXISTS:" + outputFilename, nil + } + + // Request download URL from ALL APIs in parallel - use first success + successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) if err != nil { return "", err } - var lastError error - for i, apiURL := range apis { - fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL) - - fallbackDownloader := NewTidalDownloader(apiURL) - - result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) - if err == nil { - fmt.Printf("✓ Success with: %s\n", apiURL) - return result, nil - } - - lastError = err - errMsg := err.Error() - if len(errMsg) > 80 { - errMsg = errMsg[:80] - } - fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg) + // Download the file using the successful API + fmt.Printf("Downloading to: %s\n", outputFilename) + downloader := NewTidalDownloader(successAPI) + if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { + return "", fmt.Errorf("download failed: %w", err) } - return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) + // Success! Add metadata + fmt.Println("Adding metadata...") + + coverPath := "" + if trackInfo.Album.Cover != "" { + coverPath = outputFilename + ".cover.jpg" + albumArt, err := downloader.DownloadAlbumArt(trackInfo.Album.Cover) + if err != nil { + fmt.Printf("Warning: Failed to download album art: %v\n", err) + } else { + if err := os.WriteFile(coverPath, albumArt, 0644); err != nil { + fmt.Printf("Warning: Failed to save album art: %v\n", err) + } else { + defer os.Remove(coverPath) + fmt.Println("Album art downloaded") + } + } + } + + releaseYear := "" + if len(trackInfo.Album.ReleaseDate) >= 4 { + releaseYear = trackInfo.Album.ReleaseDate[:4] + } + + trackNumberToEmbed := 0 + if position > 0 { + if useAlbumTrackNumber && trackInfo.TrackNumber > 0 { + trackNumberToEmbed = trackInfo.TrackNumber + } else { + trackNumberToEmbed = position + } + } + + metadata := Metadata{ + Title: finalTrackTitle, + Artist: finalArtistName, + Album: finalAlbumTitle, + Date: releaseYear, + TrackNumber: trackNumberToEmbed, + DiscNumber: trackInfo.VolumeNumber, + ISRC: trackInfo.ISRC, + } + + if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { + fmt.Printf("Tagging failed: %v\n", err) + } else { + fmt.Println("Metadata saved") + } + + fmt.Println("Done") + fmt.Println("✓ Downloaded successfully from Tidal (via search)") + return outputFilename, nil +} + +func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { + // Get Tidal URL once + tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) + if err != nil { + // Songlink failed to find Tidal URL, try search fallback with all APIs + fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) + fmt.Println("Trying Tidal search fallback with all APIs...") + return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, "", 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + } + + // Use parallel API requests via DownloadByURLWithFallback + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) +} + +// DownloadWithFallbackAndISRC downloads with ISRC matching for search fallback +// Uses parallel API requests for faster download +func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool, expectedDuration int) (string, error) { + // Get Tidal URL once + tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) + if err != nil { + // Songlink failed to find Tidal URL, try search fallback with ISRC matching + fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err) + fmt.Println("Trying Tidal search fallback with ISRC matching...") + return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + } + + // Use parallel API requests via DownloadByURLWithFallback + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) } func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx index 72be2b3..38c8198 100644 --- a/frontend/src/components/AudioAnalysis.tsx +++ b/frontend/src/components/AudioAnalysis.tsx @@ -7,7 +7,9 @@ import { Radio, TrendingUp, FileAudio, - Clock + Clock, + Gauge, + HardDrive } from "lucide-react"; import type { AnalysisResult } from "@/types/api"; @@ -75,6 +77,21 @@ export function AudioAnalysis({ 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 ( @@ -124,6 +141,22 @@ export function AudioAnalysis({

{formatDuration(result.duration)}

+ +
+
+ + Nyquist Frequency +
+

{(nyquistFreq / 1000).toFixed(1)} kHz

+
+ +
+
+ + Data Size +
+

{formatDataSize(dataSizeMB)}

+
{/* Dynamic Range Analysis */} diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index 9454187..f0cfa41 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -27,15 +27,15 @@ export function SpectrumVisualization({ const height = canvas.height; // Calculate margins for labels - const marginLeft = 80; - const marginRight = 80; - const marginTop = 20; - const marginBottom = 50; + const marginLeft = 70; // More space for Frequency label + const marginRight = 70; // Space for color bar + const marginTop = 30; // More space at top + const marginBottom = 65; // More space at bottom for Time label const plotWidth = width - marginLeft - marginRight; const plotHeight = height - marginTop - marginBottom; - // Black background like Spek + // Black background ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, width, height); @@ -51,9 +51,11 @@ export function SpectrumVisualization({ plotHeight, 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]); const drawRealSpectrum = ( @@ -70,40 +72,45 @@ export function SpectrumVisualization({ const freqBins = timeSlices[0].magnitudes.length; const nyquistFreq = spectrum.max_freq; + // Find min/max dB values let minDB = 0; - let maxDB = -120; + let maxDB = -200; timeSlices.forEach((slice) => { slice.magnitudes.forEach((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 sliceWidth = Math.ceil(width / timeSlices.length); + for (let t = 0; t < timeSlices.length; t++) { const slice = timeSlices[t]; 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++) { const db = slice.magnitudes[f]; - // Linear frequency scale like Spek + // Linear frequency scale const freq = (f / freqBins) * nyquistFreq; const freqRatio = freq / nyquistFreq; const yPos = y + height - (freqRatio * height); - // Calculate next frequency bin position + // Calculate bin height const nextFreq = ((f + 1) / freqBins) * nyquistFreq; const nextFreqRatio = nextFreq / nyquistFreq; const nextYPos = y + height - (nextFreqRatio * height); 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); ctx.fillStyle = color; @@ -112,161 +119,168 @@ export function SpectrumVisualization({ } }; + // Vibrant color scheme like Spek - NGEJERENG! const getSpekColor = (intensity: number): string => { - // Enhanced color scheme - better than Spek - if (intensity < 0.10) { - // Deep black to dark blue - const t = intensity / 0.10; - return `rgb(0, 0, ${Math.floor(t * 100)})`; - } else if (intensity < 0.25) { - // Dark blue to bright blue - const t = (intensity - 0.10) / 0.15; - return `rgb(0, ${Math.floor(t * 50)}, ${Math.floor(100 + t * 155)})`; + if (intensity < 0.08) { + // Black to deep blue + const t = intensity / 0.08; + return `rgb(0, 0, ${Math.floor(t * 80)})`; + } else if (intensity < 0.18) { + // Deep blue to bright blue + const t = (intensity - 0.08) / 0.10; + return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`; + } 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) { - // Blue to cyan - const t = (intensity - 0.25) / 0.15; - return `rgb(0, ${Math.floor(50 + t * 205)}, 255)`; - } else if (intensity < 0.55) { - // Cyan to green - const t = (intensity - 0.40) / 0.15; - return `rgb(0, 255, ${Math.floor(255 - t * 200)})`; - } else if (intensity < 0.70) { - // Green to yellow - const t = (intensity - 0.55) / 0.15; - return `rgb(${Math.floor(t * 255)}, 255, ${Math.floor(55 - t * 55)})`; - } else if (intensity < 0.85) { - // Yellow to orange - const t = (intensity - 0.70) / 0.15; - return `rgb(255, ${Math.floor(255 - t * 100)}, 0)`; + // Magenta to bright red + const t = (intensity - 0.28) / 0.12; + return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`; + } else if (intensity < 0.52) { + // Red to orange-red + const t = (intensity - 0.40) / 0.12; + return `rgb(255, ${Math.floor(t * 100)}, 0)`; + } else if (intensity < 0.65) { + // Orange-red to bright orange + const t = (intensity - 0.52) / 0.13; + return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`; + } else if (intensity < 0.78) { + // Orange to yellow-orange + const t = (intensity - 0.65) / 0.13; + 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 { - // Orange to red - const t = (intensity - 0.85) / 0.15; - return `rgb(255, ${Math.floor(155 - t * 155)}, ${Math.floor(t * 30)})`; + // Yellow to white (hottest) + const t = (intensity - 0.90) / 0.10; + return `rgb(255, 255, ${Math.floor(130 + t * 125)})`; } }; - const drawGrid = ( + const drawAxesAndLabels = ( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, - nyquistFreq: number + nyquistFreq: number, + duration: number, + sampleRate: number ) => { - // Enhanced grid lines - ctx.strokeStyle = "rgba(255, 255, 255, 0.08)"; - ctx.lineWidth = 1; + // Frequency labels on Y-axis + ctx.fillStyle = "#CCCCCC"; + ctx.font = "12px Arial"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; - // Dynamic frequency grid lines based on Nyquist frequency - const generateFreqLines = (maxFreq: number): number[] => { - 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); + // Generate frequency labels based on Nyquist + const freqLabels = generateFreqLabels(nyquistFreq); - 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 => { + freqLabels.forEach(freq => { if (freq <= nyquistFreq) { const freqRatio = freq / nyquistFreq; const yPos = y + height - (freqRatio * height); const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; - - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - ctx.fillText(label, x - 6, yPos); + ctx.fillText(label, x - 8, yPos); } }); - // Time labels + // "0" at bottom + ctx.fillText("0", x - 8, y + height); + + // Time labels on X-axis ctx.textAlign = "center"; ctx.textBaseline = "top"; - for (let i = 0; i <= 10; i++) { - const timePos = x + (i / 10) * width; - const timeValue = (i / 10) * duration; - if (i % 2 === 0) { - ctx.fillText(timeValue.toFixed(1), timePos, y + height + 5); - } + + const timeStep = getTimeStep(duration); + for (let t = 0; t <= duration; t += timeStep) { + const xPos = x + (t / duration) * width; + ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8); } + // Axis titles ctx.fillStyle = "#FFFFFF"; - ctx.font = "bold 13px Arial"; - ctx.shadowColor = "rgba(0, 0, 0, 0.8)"; - ctx.shadowBlur = 4; + ctx.font = "13px Arial"; + // Y-axis title: "Frequency (Hz)" ctx.save(); - ctx.translate(8, y + height / 2); + ctx.translate(12, y + height / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; - ctx.fillText("Frequency (kHz)", 0, 0); + ctx.fillText("Frequency (Hz)", 0, 0); ctx.restore(); + // X-axis title: "Time (seconds)" ctx.textAlign = "center"; - ctx.fillText("Time (s)", x + width / 2, y + height + 26); - ctx.shadowBlur = 0; + ctx.fillText("Time (seconds)", x + width / 2, y + height + 35); - const boxGradient = ctx.createLinearGradient(x + width - 200, y + 5, x + width - 200, y + 68); - boxGradient.addColorStop(0, "rgba(0, 0, 0, 0.85)"); - boxGradient.addColorStop(1, "rgba(0, 0, 0, 0.7)"); - ctx.fillStyle = boxGradient; - ctx.fillRect(x + width - 200, y + 5, 190, 63); - - ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"; - ctx.lineWidth = 1.5; - ctx.strokeRect(x + width - 200, y + 5, 190, 63); + // Sample rate info in top right + ctx.textAlign = "right"; + ctx.fillStyle = "#CCCCCC"; + ctx.font = "12px Arial"; + ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3); + }; + const generateFreqLabels = (nyquistFreq: number): number[] => { + if (nyquistFreq <= 24000) { + 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.font = "600 11px Arial"; + ctx.font = "11px Arial"; ctx.textAlign = "left"; - ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; - ctx.shadowBlur = 2; - ctx.fillText(`Sample Rate: ${(sampleRate / 1000).toFixed(1)} kHz`, x + width - 190, y + 20); - ctx.fillText(`Bit Depth: ${bitsPerSample}-bit`, x + width - 190, y + 36); - ctx.fillText(`Nyquist: ${(nyquistFreq / 1000).toFixed(1)} kHz`, x + width - 190, y + 52); - ctx.shadowBlur = 0; + ctx.textBaseline = "middle"; + + ctx.fillText("High", x + width + 5, y + 10); + ctx.fillText("Low", x + width + 5, y + height - 10); }; return (
diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 6fe81f4..28bdc8c 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -29,7 +29,8 @@ export function useDownload() { playlistName?: string, isArtistDiscography?: boolean, position?: number, - spotifyId?: string + spotifyId?: string, + durationMs?: number ) => { 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 if (streamingURLs?.tidal_url) { try { @@ -90,6 +94,7 @@ export function useDownload() { use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, service_url: streamingURLs.tidal_url, + duration: durationSeconds, }); if (tidalResponse.success) { @@ -167,6 +172,9 @@ export function useDownload() { 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({ isrc, service: service as "deezer" | "tidal" | "qobuz" | "amazon", @@ -180,6 +188,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + duration: durationSecondsForFallback, }); }; @@ -190,7 +199,8 @@ export function useDownload() { albumName?: string, spotifyId?: string, playlistName?: string, - isArtistDiscography?: boolean + isArtistDiscography?: boolean, + durationMs?: number ) => { if (!isrc) { toast.error("No ISRC found for this track"); @@ -212,7 +222,8 @@ export function useDownload() { playlistName, isArtistDiscography, undefined, // Don't pass position for single track - spotifyId + spotifyId, + durationMs ); if (response.success) { @@ -290,7 +301,8 @@ export function useDownload() { playlistName, isArtistDiscography, i + 1, // Sequential position based on selection order - track?.spotify_id + track?.spotify_id, + track?.duration_ms ); if (response.success) { @@ -394,7 +406,8 @@ export function useDownload() { playlistName, isArtistDiscography, i + 1, - track.spotify_id + track.spotify_id, + track.duration_ms ); if (response.success) { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 600bd91..1358950 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -124,6 +124,7 @@ export interface DownloadRequest { use_album_track_number?: boolean; spotify_id?: string; service_url?: string; + duration?: number; // Track duration in seconds for better matching } export interface DownloadResponse {