diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 58475e5..2fcb24e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,13 +78,13 @@ jobs: - name: Prepare artifacts run: | mkdir -p dist - Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC-${{ steps.version.outputs.version }}.exe" + Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe" - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: windows-portable - path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.exe + path: dist/SpotiFLAC.exe retention-days: 7 build-macos: @@ -159,16 +159,16 @@ jobs: --icon "SpotiFLAC.app" 175 120 \ --hide-extension "SpotiFLAC.app" \ --app-drop-link 425 120 \ - "dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg" \ + "dist/SpotiFLAC.dmg" \ "build/bin/SpotiFLAC.app" || \ # Fallback to hdiutil if create-dmg fails - hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg + hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: macos-portable - path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg + path: dist/SpotiFLAC.dmg retention-days: 7 build-linux: @@ -291,13 +291,13 @@ jobs: # Create AppImage mkdir -p dist - ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage + ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: linux-portable - path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage + path: dist/SpotiFLAC.AppImage retention-days: 7 create-release: @@ -336,9 +336,9 @@ jobs: ## Downloads - - `SpotiFLAC-${{ steps.version.outputs.version }}.exe` - Windows - - `SpotiFLAC-${{ steps.version.outputs.version }}.dmg` - macOS - - `SpotiFLAC-${{ steps.version.outputs.version }}.AppImage` - Linux + - `SpotiFLAC.exe` - Windows + - `SpotiFLAC.dmg` - macOS + - `SpotiFLAC.AppImage` - Linux
Linux Requirements @@ -362,8 +362,8 @@ jobs: After installing the dependency, make the AppImage executable: ```bash - chmod +x SpotiFLAC-${{ steps.version.outputs.version }}.AppImage - ./SpotiFLAC-${{ steps.version.outputs.version }}.AppImage + chmod +x SpotiFLAC.AppImage + ./SpotiFLAC.AppImage ```
diff --git a/app.go b/app.go index fbb3e5c..c443a82 100644 --- a/app.go +++ b/app.go @@ -50,6 +50,7 @@ type DownloadRequest struct { TrackNumber bool `json:"track_number,omitempty"` Position int `json:"position,omitempty"` // Position in playlist/album (1-based) 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 for Amazon Music } // DownloadResponse represents the response structure for download operations @@ -138,7 +139,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { backend.SetDownloading(true) defer backend.SetDownloading(false) - if req.Service == "tidal" { + if req.Service == "amazon" { + if req.SpotifyID == "" { + return DownloadResponse{ + Success: false, + Error: "Spotify ID is required for Amazon Music", + }, fmt.Errorf("Spotify ID is required for Amazon Music") + } + downloader := backend.NewAmazonDownloader() + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } else if req.Service == "tidal" { searchQuery := req.Query if searchQuery == "" { searchQuery = req.ISRC diff --git a/backend/amazon.go b/backend/amazon.go new file mode 100644 index 0000000..4ce2699 --- /dev/null +++ b/backend/amazon.go @@ -0,0 +1,414 @@ +package backend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +type AmazonDownloader struct { + client *http.Client + regions []string + lastAPICallTime time.Time + apiCallCount int + apiCallResetTime time.Time +} + +type SongLinkResponse struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` +} + +type DoubleDoubleSubmitResponse struct { + Success bool `json:"success"` + ID string `json:"id"` +} + +type DoubleDoubleStatusResponse struct { + Status string `json:"status"` + FriendlyStatus string `json:"friendlyStatus"` + URL string `json:"url"` + Current struct { + Name string `json:"name"` + Artist string `json:"artist"` + } `json:"current"` +} + +func NewAmazonDownloader() *AmazonDownloader { + return &AmazonDownloader{ + client: &http.Client{ + Timeout: 120 * time.Second, + }, + regions: []string{"us", "eu"}, + apiCallResetTime: time.Now(), + } +} + +func (a *AmazonDownloader) getRandomUserAgent() string { + return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", + rand.Intn(4)+11, rand.Intn(5)+4, + rand.Intn(7)+530, rand.Intn(7)+30, + rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60, + rand.Intn(7)+530, rand.Intn(6)+30) +} + +func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) { + // Rate limiting: max 10 requests per minute (song.link API limit) + // Reset counter every minute + now := time.Now() + if now.Sub(a.apiCallResetTime) >= time.Minute { + a.apiCallCount = 0 + a.apiCallResetTime = now + } + + // If we've hit the limit, wait until the next minute + if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10) + waitTime := time.Minute - now.Sub(a.apiCallResetTime) + if waitTime > 0 { + fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) + time.Sleep(waitTime) + a.apiCallCount = 0 + a.apiCallResetTime = time.Now() + } + } + + // Add delay between requests (6 seconds = 10 requests per minute) + if !a.lastAPICallTime.IsZero() { + timeSinceLastCall := now.Sub(a.lastAPICallTime) + minDelay := 7 * time.Second // 7 seconds to be safe + if timeSinceLastCall < minDelay { + waitTime := minDelay - timeSinceLastCall + fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) + time.Sleep(waitTime) + } + } + + // Decode base64 API URL + spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") + spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") + apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", a.getRandomUserAgent()) + + fmt.Println("Getting Amazon URL...") + + // Retry logic for rate limit errors + maxRetries := 3 + var resp *http.Response + for i := 0; i < maxRetries; i++ { + resp, err = a.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get Amazon URL: %w", err) + } + + // Update rate limit tracking + a.lastAPICallTime = time.Now() + a.apiCallCount++ + + if resp.StatusCode == 429 { // Too Many Requests + resp.Body.Close() + if i < maxRetries-1 { + waitTime := 15 * time.Second + fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime) + time.Sleep(waitTime) + continue + } + return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries) + } + + if resp.StatusCode != 200 { + resp.Body.Close() + return "", fmt.Errorf("API returned status %d", resp.StatusCode) + } + + break + } + defer resp.Body.Close() + + var songLinkResp SongLinkResponse + if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"] + if !ok || amazonLink.URL == "" { + return "", fmt.Errorf("amazon Music link not found") + } + + amazonURL := amazonLink.URL + + // Convert album URL to track URL if needed + if strings.Contains(amazonURL, "trackAsin=") { + parts := strings.Split(amazonURL, "trackAsin=") + if len(parts) > 1 { + trackAsin := strings.Split(parts[1], "&")[0] + musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=") + amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin) + } + } + + fmt.Printf("Found Amazon URL: %s\n", amazonURL) + return amazonURL, nil +} + +func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) { + var lastError error + + for _, region := range a.regions { + fmt.Printf("\nTrying region: %s...\n", region) + // Decode base64 service URL + serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") + serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") + baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) + + // Step 1: Submit download request + encodedURL := url.QueryEscape(amazonURL) + submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) + + req, err := http.NewRequest("GET", submitURL, nil) + if err != nil { + lastError = fmt.Errorf("failed to create request: %w", err) + continue + } + + req.Header.Set("User-Agent", a.getRandomUserAgent()) + + fmt.Println("Submitting download request...") + resp, err := a.client.Do(req) + if err != nil { + lastError = fmt.Errorf("failed to submit request: %w", err) + continue + } + + if resp.StatusCode != 200 { + resp.Body.Close() + lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode) + continue + } + + var submitResp DoubleDoubleSubmitResponse + if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil { + resp.Body.Close() + lastError = fmt.Errorf("failed to decode submit response: %w", err) + continue + } + resp.Body.Close() + + if !submitResp.Success || submitResp.ID == "" { + lastError = fmt.Errorf("submit request failed") + continue + } + + downloadID := submitResp.ID + fmt.Printf("Download ID: %s\n", downloadID) + + // Step 2: Poll for completion + statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) + fmt.Println("Waiting for download to complete...") + + maxWait := 300 * time.Second + elapsed := time.Duration(0) + pollInterval := 3 * time.Second + + for elapsed < maxWait { + time.Sleep(pollInterval) + elapsed += pollInterval + + statusReq, err := http.NewRequest("GET", statusURL, nil) + if err != nil { + continue + } + + statusReq.Header.Set("User-Agent", a.getRandomUserAgent()) + + statusResp, err := a.client.Do(statusReq) + if err != nil { + fmt.Printf("\rStatus check failed, retrying...") + continue + } + + if statusResp.StatusCode != 200 { + statusResp.Body.Close() + fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode) + continue + } + + var status DoubleDoubleStatusResponse + if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil { + statusResp.Body.Close() + fmt.Printf("\rInvalid JSON response, retrying...") + continue + } + statusResp.Body.Close() + + if status.Status == "done" { + fmt.Println("\nDownload ready!") + + // Build download URL + fileURL := status.URL + if strings.HasPrefix(fileURL, "./") { + fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:]) + } else if strings.HasPrefix(fileURL, "/") { + fileURL = fmt.Sprintf("%s%s", baseURL, fileURL) + } + + trackName := status.Current.Name + artist := status.Current.Artist + + fmt.Printf("Downloading: %s - %s\n", artist, trackName) + + // Download file + downloadReq, err := http.NewRequest("GET", fileURL, nil) + if err != nil { + lastError = fmt.Errorf("failed to create download request: %w", err) + break + } + + downloadReq.Header.Set("User-Agent", a.getRandomUserAgent()) + + fileResp, err := a.client.Do(downloadReq) + if err != nil { + lastError = fmt.Errorf("failed to download file: %w", err) + break + } + defer fileResp.Body.Close() + + if fileResp.StatusCode != 200 { + lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode) + break + } + + // Generate filename + fileName := fmt.Sprintf("%s - %s.flac", artist, trackName) + for _, char := range `<>:"/\|?*` { + fileName = strings.ReplaceAll(fileName, string(char), "") + } + fileName = strings.TrimSpace(fileName) + + filePath := filepath.Join(outputDir, fileName) + + // Save file + out, err := os.Create(filePath) + if err != nil { + lastError = fmt.Errorf("failed to create file: %w", err) + break + } + defer out.Close() + + fmt.Println("Downloading...") + // Use progress writer to track download + pw := NewProgressWriter(out) + _, err = io.Copy(pw, fileResp.Body) + if err != nil { + out.Close() + return "", fmt.Errorf("failed to write file: %w", err) + } + + // Print final size + fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) + fmt.Println("Download complete!") + return filePath, nil + + } else if status.Status == "error" { + errorMsg := status.FriendlyStatus + if errorMsg == "" { + errorMsg = "Unknown error" + } + lastError = fmt.Errorf("processing failed: %s", errorMsg) + break + } else { + // Still processing + friendlyStatus := status.FriendlyStatus + if friendlyStatus == "" { + friendlyStatus = status.Status + } + fmt.Printf("\r%s...", friendlyStatus) + } + } + + if elapsed >= maxWait { + lastError = fmt.Errorf("download timeout") + fmt.Printf("\nError with %s region: %v\n", region, lastError) + continue + } + + if lastError != nil { + fmt.Printf("\nError with %s region: %v\n", region, lastError) + } + } + + return "", fmt.Errorf("all regions failed. Last error: %v", lastError) +} + +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { + // Create output directory if needed + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + } + + // Get Amazon URL from Spotify track ID + amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) + if err != nil { + return "", err + } + + // Download from service + filePath, err := a.DownloadFromService(amazonURL, outputDir) + if err != nil { + return "", err + } + + // File already has embedded metadata, just rename if needed + if spotifyTrackName != "" && spotifyArtistName != "" { + safeArtist := sanitizeFilename(spotifyArtistName) + safeTitle := sanitizeFilename(spotifyTrackName) + + // Build filename based on format settings + var newFilename string + switch filenameFormat { + case "artist-title": + newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) + case "title": + newFilename = safeTitle + default: // "title-artist" + newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) + } + + // Add track number prefix if enabled + if includeTrackNumber && position > 0 { + newFilename = fmt.Sprintf("%02d. %s", position, newFilename) + } + + newFilename = newFilename + ".flac" + newFilePath := filepath.Join(outputDir, newFilename) + + // Rename file + if err := os.Rename(filePath, newFilePath); err != nil { + fmt.Printf("Warning: Failed to rename file: %v\n", err) + } else { + filePath = newFilePath + fmt.Printf("Renamed to: %s\n", newFilename) + } + } + + fmt.Println("Done") + return filePath, nil +} diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 9873791..50ae6ff 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -66,6 +66,7 @@ type TrackMetadata struct { TrackNumber int `json:"track_number"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` + SpotifyID string `json:"spotify_id,omitempty"` } // AlbumTrackMetadata holds per-track info for album / playlist formatting. @@ -80,6 +81,7 @@ type AlbumTrackMetadata struct { ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` AlbumType string `json:"album_type,omitempty"` + SpotifyID string `json:"spotify_id,omitempty"` } type TrackResponse struct { @@ -487,6 +489,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes TrackNumber: item.Track.TrackNumber, ExternalURL: item.Track.ExternalURL.Spotify, ISRC: item.Track.ExternalID.ISRC, + SpotifyID: item.Track.ID, }) } @@ -523,6 +526,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumR TrackNumber: item.TrackNumber, ExternalURL: item.ExternalURL.Spotify, ISRC: isrc, + SpotifyID: item.ID, }) } @@ -588,6 +592,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, TrackNumber: tr.TrackNumber, ExternalURL: tr.ExternalURL.Spotify, ISRC: isrc, + SpotifyID: tr.ID, }) } } @@ -628,6 +633,7 @@ func formatTrackData(raw *trackFull) TrackResponse { TrackNumber: raw.TrackNumber, ExternalURL: raw.ExternalURL.Spotify, ISRC: raw.ExternalID.ISRC, + SpotifyID: raw.ID, }, } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b78a7b..8f7c4b6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,7 +40,7 @@ function App() { const [hasUpdate, setHasUpdate] = useState(false); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "5.9"; + const CURRENT_VERSION = "6.0"; const download = useDownload(); const metadata = useMetadata(); @@ -78,7 +78,7 @@ function App() { const checkForUpdates = async () => { try { const response = await fetch( - "https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json" + "https://cdn.jsdelivr.net/gh/afkarxyz/SpotiFLAC@refs/heads/main/version.json" ); const data = await response.json(); const latestVersion = data.version; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1365340..a8164cf 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -49,7 +49,7 @@ export function Header({ version, hasUpdate }: HeaderProps) {

- Get Spotify tracks in true FLAC from Tidal, Deezer & Qobuz — no account required. + Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.

diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 9296d8a..375eb20 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -46,6 +46,13 @@ const QobuzIcon = () => ( ); +const AmazonIcon = () => ( + + + + +); + export function Settings() { const [open, setOpen] = useState(false); const [savedSettings, setSavedSettings] = useState(getSettings()); @@ -156,7 +163,7 @@ export function Settings() { setTempSettings((prev) => ({ ...prev, downloadPath: value })); }; - const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz") => { + const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => { setTempSettings((prev) => ({ ...prev, downloader: value })); }; @@ -246,6 +253,12 @@ export function Settings() { Qobuz + + + + Amazon Music + +
diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index f7f9095..8002772 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -9,7 +9,7 @@ interface TrackInfoProps { isDownloading: boolean; downloadingTrack: string | null; isDownloaded: boolean; - onDownload: (isrc: string, name: string, artists: string) => void; + onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void; onOpenFolder: () => void; } @@ -50,7 +50,7 @@ export function TrackInfo({ {track.isrc && (