v6.0
This commit is contained in:
+12
-12
@@ -78,13 +78,13 @@ jobs:
|
|||||||
- name: Prepare artifacts
|
- name: Prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-portable
|
name: windows-portable
|
||||||
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.exe
|
path: dist/SpotiFLAC.exe
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -159,16 +159,16 @@ jobs:
|
|||||||
--icon "SpotiFLAC.app" 175 120 \
|
--icon "SpotiFLAC.app" 175 120 \
|
||||||
--hide-extension "SpotiFLAC.app" \
|
--hide-extension "SpotiFLAC.app" \
|
||||||
--app-drop-link 425 120 \
|
--app-drop-link 425 120 \
|
||||||
"dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg" \
|
"dist/SpotiFLAC.dmg" \
|
||||||
"build/bin/SpotiFLAC.app" || \
|
"build/bin/SpotiFLAC.app" || \
|
||||||
# Fallback to hdiutil if create-dmg fails
|
# 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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-portable
|
name: macos-portable
|
||||||
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg
|
path: dist/SpotiFLAC.dmg
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
@@ -291,13 +291,13 @@ jobs:
|
|||||||
|
|
||||||
# Create AppImage
|
# Create AppImage
|
||||||
mkdir -p dist
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: linux-portable
|
name: linux-portable
|
||||||
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
|
path: dist/SpotiFLAC.AppImage
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
@@ -336,9 +336,9 @@ jobs:
|
|||||||
|
|
||||||
## Downloads
|
## Downloads
|
||||||
|
|
||||||
- `SpotiFLAC-${{ steps.version.outputs.version }}.exe` - Windows
|
- `SpotiFLAC.exe` - Windows
|
||||||
- `SpotiFLAC-${{ steps.version.outputs.version }}.dmg` - macOS
|
- `SpotiFLAC.dmg` - macOS
|
||||||
- `SpotiFLAC-${{ steps.version.outputs.version }}.AppImage` - Linux
|
- `SpotiFLAC.AppImage` - Linux
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Linux Requirements</b></summary>
|
<summary><b>Linux Requirements</b></summary>
|
||||||
@@ -362,8 +362,8 @@ jobs:
|
|||||||
|
|
||||||
After installing the dependency, make the AppImage executable:
|
After installing the dependency, make the AppImage executable:
|
||||||
```bash
|
```bash
|
||||||
chmod +x SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
|
chmod +x SpotiFLAC.AppImage
|
||||||
./SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
|
./SpotiFLAC.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type DownloadRequest struct {
|
|||||||
TrackNumber bool `json:"track_number,omitempty"`
|
TrackNumber bool `json:"track_number,omitempty"`
|
||||||
Position int `json:"position,omitempty"` // Position in playlist/album (1-based)
|
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
|
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
|
// DownloadResponse represents the response structure for download operations
|
||||||
@@ -138,7 +139,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
backend.SetDownloading(true)
|
backend.SetDownloading(true)
|
||||||
defer backend.SetDownloading(false)
|
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
|
searchQuery := req.Query
|
||||||
if searchQuery == "" {
|
if searchQuery == "" {
|
||||||
searchQuery = req.ISRC
|
searchQuery = req.ISRC
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ type TrackMetadata struct {
|
|||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
||||||
@@ -80,6 +81,7 @@ type AlbumTrackMetadata struct {
|
|||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
@@ -487,6 +489,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
|||||||
TrackNumber: item.Track.TrackNumber,
|
TrackNumber: item.Track.TrackNumber,
|
||||||
ExternalURL: item.Track.ExternalURL.Spotify,
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||||
ISRC: item.Track.ExternalID.ISRC,
|
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,
|
TrackNumber: item.TrackNumber,
|
||||||
ExternalURL: item.ExternalURL.Spotify,
|
ExternalURL: item.ExternalURL.Spotify,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
|
SpotifyID: item.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,6 +592,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
TrackNumber: tr.TrackNumber,
|
TrackNumber: tr.TrackNumber,
|
||||||
ExternalURL: tr.ExternalURL.Spotify,
|
ExternalURL: tr.ExternalURL.Spotify,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
|
SpotifyID: tr.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,6 +633,7 @@ func formatTrackData(raw *trackFull) TrackResponse {
|
|||||||
TrackNumber: raw.TrackNumber,
|
TrackNumber: raw.TrackNumber,
|
||||||
ExternalURL: raw.ExternalURL.Spotify,
|
ExternalURL: raw.ExternalURL.Spotify,
|
||||||
ISRC: raw.ExternalID.ISRC,
|
ISRC: raw.ExternalID.ISRC,
|
||||||
|
SpotifyID: raw.ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function App() {
|
|||||||
const [hasUpdate, setHasUpdate] = useState(false);
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "5.9";
|
const CURRENT_VERSION = "6.0";
|
||||||
|
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
@@ -78,7 +78,7 @@ function App() {
|
|||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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 data = await response.json();
|
||||||
const latestVersion = data.version;
|
const latestVersion = data.version;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 top-0 flex gap-2">
|
<div className="absolute right-0 top-0 flex gap-2">
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ const QobuzIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const AmazonIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ display: "block", width: "1.1em", height: "1.1em" }} className="inline-block mr-2">
|
||||||
|
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||||
|
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||||
@@ -156,7 +163,7 @@ export function Settings() {
|
|||||||
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
|
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 }));
|
setTempSettings((prev) => ({ ...prev, downloader: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,6 +253,12 @@ export function Settings() {
|
|||||||
Qobuz
|
Qobuz
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<AmazonIcon />
|
||||||
|
Amazon Music
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface TrackInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloaded: boolean;
|
isDownloaded: boolean;
|
||||||
onDownload: (isrc: string, name: string, artists: string) => void;
|
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export function TrackInfo({
|
|||||||
{track.isrc && (
|
{track.isrc && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onDownload(track.isrc, track.name, track.artists)}
|
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
|
||||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
>
|
>
|
||||||
{downloadingTrack === track.isrc ? (
|
{downloadingTrack === track.isrc ? (
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export function useDownload() {
|
|||||||
albumName?: string,
|
albumName?: string,
|
||||||
playlistName?: string,
|
playlistName?: string,
|
||||||
isArtistDiscography?: boolean,
|
isArtistDiscography?: boolean,
|
||||||
position?: number
|
position?: number,
|
||||||
|
spotifyId?: string
|
||||||
) => {
|
) => {
|
||||||
let service = settings.downloader;
|
let service = settings.downloader;
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export function useDownload() {
|
|||||||
|
|
||||||
return await downloadTrack({
|
return await downloadTrack({
|
||||||
isrc,
|
isrc,
|
||||||
service: service as "deezer" | "tidal" | "qobuz",
|
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: artistName,
|
||||||
@@ -118,6 +119,7 @@ export function useDownload() {
|
|||||||
track_number: settings.trackNumber,
|
track_number: settings.trackNumber,
|
||||||
position,
|
position,
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +127,8 @@ export function useDownload() {
|
|||||||
isrc: string,
|
isrc: string,
|
||||||
trackName?: string,
|
trackName?: string,
|
||||||
artistName?: string,
|
artistName?: string,
|
||||||
albumName?: string
|
albumName?: string,
|
||||||
|
spotifyId?: string
|
||||||
) => {
|
) => {
|
||||||
if (!isrc) {
|
if (!isrc) {
|
||||||
toast.error("No ISRC found for this track");
|
toast.error("No ISRC found for this track");
|
||||||
@@ -145,7 +148,8 @@ export function useDownload() {
|
|||||||
albumName,
|
albumName,
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
undefined // Don't pass position for single track
|
undefined, // Don't pass position for single track
|
||||||
|
spotifyId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -212,7 +216,8 @@ export function useDownload() {
|
|||||||
track?.album_name,
|
track?.album_name,
|
||||||
playlistName,
|
playlistName,
|
||||||
isArtistDiscography,
|
isArtistDiscography,
|
||||||
i + 1 // Sequential position based on selection order
|
i + 1, // Sequential position based on selection order
|
||||||
|
track?.spotify_id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -284,7 +289,8 @@ export function useDownload() {
|
|||||||
track.album_name,
|
track.album_name,
|
||||||
playlistName,
|
playlistName,
|
||||||
isArtistDiscography,
|
isArtistDiscography,
|
||||||
i + 1
|
i + 1,
|
||||||
|
track.spotify_id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { GetDefaults } from "../../wailsjs/go/main/App";
|
|||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
downloader: "auto" | "deezer" | "tidal" | "qobuz";
|
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
|
||||||
theme: string;
|
theme: string;
|
||||||
themeMode: "auto" | "light" | "dark";
|
themeMode: "auto" | "light" | "dark";
|
||||||
filenameFormat: "title-artist" | "artist-title" | "title";
|
filenameFormat: "title-artist" | "artist-title" | "title";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface TrackMetadata {
|
|||||||
external_urls: string;
|
external_urls: string;
|
||||||
isrc: string;
|
isrc: string;
|
||||||
album_type?: string;
|
album_type?: string;
|
||||||
|
spotify_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackResponse {
|
export interface TrackResponse {
|
||||||
@@ -97,7 +98,7 @@ export type SpotifyMetadataResponse =
|
|||||||
|
|
||||||
export interface DownloadRequest {
|
export interface DownloadRequest {
|
||||||
isrc: string;
|
isrc: string;
|
||||||
service: "deezer" | "tidal" | "qobuz";
|
service: "deezer" | "tidal" | "qobuz" | "amazon";
|
||||||
query?: string;
|
query?: string;
|
||||||
track_name?: string;
|
track_name?: string;
|
||||||
artist_name?: string;
|
artist_name?: string;
|
||||||
@@ -110,6 +111,7 @@ export interface DownloadRequest {
|
|||||||
track_number?: boolean;
|
track_number?: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
use_album_track_number?: boolean;
|
use_album_track_number?: boolean;
|
||||||
|
spotify_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadResponse {
|
export interface DownloadResponse {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "5.9"
|
"productVersion": "6.0"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user