Compare commits

...

7 Commits

Author SHA1 Message Date
afkarxyz 6ee3c2f653 v6.1 2025-11-24 14:52:47 +07:00
afkarxyz 73d8205f6f v6.0 2025-11-24 05:29:06 +07:00
afkarxyz 17fe37fbb7 v6.0 2025-11-24 05:22:04 +07:00
afkarxyz afe55db107 v6.0 2025-11-24 05:19:25 +07:00
afkarxyz 8a553774c6 v6.0 2025-11-24 05:15:24 +07:00
afkarxyz 869bf50330 SpotiDownloader 2025-11-24 03:35:26 +07:00
afkarxyz 1c03aaa92f v5.9 2025-11-23 10:40:27 +07:00
28 changed files with 1341 additions and 268 deletions
+12 -12
View File
@@ -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>
+13 -3
View File
@@ -4,7 +4,7 @@
<div align="center"> <div align="center">
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.
<br><br> <br><br>
@@ -18,12 +18,22 @@ Get Spotify tracks in true FLAC from Tidal, Deezer & Qobuz — no account requir
## Screenshot ## Screenshot
![Image](https://github.com/user-attachments/assets/9292b222-b1b4-4206-ac72-22f470c656d1) ![Image](https://github.com/user-attachments/assets/e021e0fc-3b71-4fff-9455-ac0145d5fe9a)
## Lossless Audio Check ## Lossless Audio Check
A simple utility for verifying the authenticity of FLAC files (lossless/lossy detection).
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
#
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a) ![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05) ![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) ## Other projects
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
+90 -10
View File
@@ -50,6 +50,8 @@ 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
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
} }
// DownloadResponse represents the response structure for download operations // DownloadResponse represents the response structure for download operations
@@ -61,6 +63,27 @@ type DownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"`
} }
// GetStreamingURLs fetches all streaming URLs from song.link API
func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) {
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID)
client := backend.NewSongLinkClient()
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
jsonData, err := json.Marshal(urls)
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
// GetSpotifyMetadata fetches metadata from Spotify // GetSpotifyMetadata fetches metadata from Spotify
func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
if req.URL == "" { if req.URL == "" {
@@ -119,7 +142,18 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
req.FilenameFormat = "title-artist" req.FilenameFormat = "title-artist"
} }
// Early check: if we have track metadata, check if file already exists // Early check: Check if file with same ISRC already exists
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
return DownloadResponse{
Success: true,
Message: "File with same ISRC already exists",
File: existingFile,
AlreadyExists: true,
}, nil
}
// Fallback: if we have track metadata, check if file already exists by filename
if req.TrackName != "" && req.ArtistName != "" { if req.TrackName != "" && req.ArtistName != "" {
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber) expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename) expectedPath := filepath.Join(req.OutputDir, expectedFilename)
@@ -138,25 +172,71 @@ 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" { switch req.Service {
searchQuery := req.Query case "amazon":
if searchQuery == "" { downloader := backend.NewAmazonDownloader()
searchQuery = req.ISRC if req.ServiceURL != "" {
// Use provided URL directly
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Amazon Music",
}, fmt.Errorf("spotify ID is required for Amazon Music")
}
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} }
case "tidal":
if req.ApiURL == "" || req.ApiURL == "auto" { if req.ApiURL == "" || req.ApiURL == "auto" {
downloader := backend.NewTidalDownloader("") downloader := backend.NewTidalDownloader("")
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) if req.ServiceURL != "" {
// Use provided URL directly with fallback to multiple APIs
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
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)
}
} else { } else {
downloader := backend.NewTidalDownloader(req.ApiURL) downloader := backend.NewTidalDownloader(req.ApiURL)
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) if req.ServiceURL != "" {
// Use provided URL directly with specific API
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
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)
}
} }
} else if req.Service == "qobuz" {
case "qobuz":
downloader := backend.NewQobuzDownloader() downloader := backend.NewQobuzDownloader()
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
default: // deezer
downloader := backend.NewDeezerDownloader() downloader := backend.NewDeezerDownloader()
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) if req.ServiceURL != "" {
// Use provided URL directly
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Deezer",
}, fmt.Errorf("spotify ID is required for Deezer")
}
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
}
} }
if err != nil { if err != nil {
+432
View File
@@ -0,0 +1,432 @@
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) DownloadByURL(amazonURL, 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)
}
}
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + expectedPath, nil
}
}
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
// 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")
fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil
}
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Amazon URL from Spotify track ID
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
+96 -7
View File
@@ -56,10 +56,75 @@ func NewDeezerDownloader() *DeezerDownloader {
} }
} }
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) { func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
// Decode base64 API URL // Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
url := fmt.Sprintf("%s%s", string(apiBase), isrc) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), spotifyURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL...")
resp, err := d.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) {
// Extract track ID from Deezer URL
// Format: https://www.deezer.com/track/3412534581
parts := strings.Split(deezerURL, "/track/")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid Deezer URL format")
}
// Get the track ID part and remove any query parameters
trackIDStr := strings.Split(parts[1], "?")[0]
trackIDStr = strings.TrimSpace(trackIDStr)
var trackID int64
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
if err != nil {
return 0, fmt.Errorf("failed to parse track ID: %w", err)
}
return trackID, nil
}
func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv")
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
resp, err := d.client.Get(url) resp, err := d.client.Get(url)
if err != nil { if err != nil {
@@ -77,7 +142,7 @@ func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
} }
if track.ID == 0 { if track.ID == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc) return nil, fmt.Errorf("track not found")
} }
return &track, nil return &track, nil
@@ -187,10 +252,17 @@ func buildFilename(title, artist string, trackNumber int, format string, include
return filename + ".flac" return filename + ".flac"
} }
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc) fmt.Printf("Using Deezer URL: %s\n", deezerURL)
track, err := d.GetTrackByISRC(isrc) // Extract track ID from URL
trackID, err := d.GetTrackIDFromURL(deezerURL)
if err != nil {
return "", err
}
// Get track info by ID
track, err := d.GetTrackByID(trackID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -234,6 +306,12 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
safeArtist := sanitizeFilename(artists) safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle) safeTitle := sanitizeFilename(trackTitle)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings // Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename) filepath := filepath.Join(outputDir, filename)
@@ -287,5 +365,16 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
} }
fmt.Println("Metadata embedded successfully!") fmt.Println("Metadata embedded successfully!")
fmt.Println("✓ Downloaded successfully from Deezer")
return filepath, nil return filepath, nil
} }
func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Deezer URL from Spotify track ID
deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
+71
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis" "github.com/go-flac/flacvorbis"
@@ -111,3 +112,73 @@ func fileExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
return err == nil return err == nil
} }
// ReadISRCFromFile reads ISRC metadata from a FLAC file
func ReadISRCFromFile(filepath string) (string, error) {
if !fileExists(filepath) {
return "", fmt.Errorf("file does not exist")
}
f, err := flac.ParseFile(filepath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find VorbisComment block
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
// Get ISRC field
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
if err == nil && len(isrcValues) > 0 {
return isrcValues[0], nil
}
}
}
return "", nil // No ISRC found
}
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
if targetISRC == "" {
return "", false
}
// Read all .flac files in directory
entries, err := os.ReadDir(outputDir)
if err != nil {
return "", false
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// Check only .flac files
filename := entry.Name()
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
continue
}
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
// Read ISRC from file
isrc, err := ReadISRCFromFile(filepath)
if err != nil {
continue
}
// Compare ISRC (case-insensitive)
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
return filepath, true
}
}
return "", false
}
+6
View File
@@ -315,6 +315,12 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists) safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle) safeTitle := sanitizeFilename(trackTitle)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings // Build filename based on format settings
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename) filepath := filepath.Join(outputDir, filename)
+150
View File
@@ -0,0 +1,150 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type SongLinkClient struct {
client *http.Client
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
}
type SongLinkURLs struct {
TidalURL string `json:"tidal_url"`
DeezerURL string `json:"deezer_url"`
AmazonURL string `json:"amazon_url"`
}
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
apiCallResetTime: time.Now(),
}
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
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 nil, fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting streaming URLs from song.link...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get URLs: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
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 nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
urls := &SongLinkURLs{}
// Extract Tidal URL
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n")
}
// Extract Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
urls.DeezerURL = deezerLink.URL
fmt.Printf("✓ Deezer URL found\n")
}
// Extract Amazon URL
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n")
}
}
// Check if at least one URL was found
if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
return urls, nil
}
+8 -7
View File
@@ -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 {
@@ -269,11 +271,6 @@ type artistResponse struct {
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
} }
type artistAlbumsResponse struct {
Items []albumSimplified `json:"items"`
Next string `json:"next"`
}
type playlistRaw struct { type playlistRaw struct {
Data playlistResponse Data playlistResponse
BatchEnabled bool BatchEnabled bool
@@ -319,7 +316,7 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
return nil, err return nil, err
} }
return c.processSpotifyData(ctx, raw, parsed.Type) return c.processSpotifyData(ctx, raw)
} }
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) { func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) {
@@ -339,7 +336,7 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
} }
} }
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, dataType string) (interface{}, error) { func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
switch payload := raw.(type) { switch payload := raw.(type) {
case *playlistRaw: case *playlistRaw:
return c.formatPlaylistData(payload), nil return c.formatPlaylistData(payload), nil
@@ -487,6 +484,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 +521,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 +587,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 +628,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,
}, },
} }
} }
+168 -79
View File
@@ -22,13 +22,6 @@ type TidalDownloader struct {
apiURL string apiURL string
} }
type TidalSearchResponse struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []TidalTrack `json:"items"`
}
type TidalTrack struct { type TidalTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -68,6 +61,26 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
// If apiURL is empty, try to get first available API
if apiURL == "" {
downloader := &TidalDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
timeout: 30 * time.Second,
maxRetries: 3,
clientID: string(clientID),
clientSecret: string(clientSecret),
apiURL: "",
}
// Try to get available APIs
apis, err := downloader.GetAvailableAPIs()
if err == nil && len(apis) > 0 {
apiURL = apis[0] // Use first available API
}
}
return &TidalDownloader{ return &TidalDownloader{
client: &http.Client{ client: &http.Client{
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
@@ -155,17 +168,83 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return result.AccessToken, nil return result.AccessToken, nil
} }
func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) { func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Tidal URL...")
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]
if !ok || tidalLink.URL == "" {
return "", fmt.Errorf("tidal link not found")
}
tidalURL := tidalLink.URL
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
return tidalURL, nil
}
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
// Extract track ID from Tidal URL
// Format: https://listen.tidal.com/track/441821360
// or: https://tidal.com/browse/track/123456789
parts := strings.Split(tidalURL, "/track/")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid tidal URL format")
}
// Get the track ID part and remove any query parameters
trackIDStr := strings.Split(parts[1], "?")[0]
trackIDStr = strings.TrimSpace(trackIDStr)
var trackID int64
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
if err != nil {
return 0, fmt.Errorf("failed to parse track ID: %w", err)
}
return trackID, nil
}
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err) return nil, fmt.Errorf("failed to get access token: %w", err)
} }
// Decode base64 API URL and encode the query parameter // Decode base64 API URL
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=")
searchURL := fmt.Sprintf("%s%s&limit=25&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query)) trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID)
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", trackURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -180,106 +259,55 @@ func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, erro
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body)) return nil, fmt.Errorf("failed to get track info: HTTP %d - %s", resp.StatusCode, string(body))
} }
var result TidalSearchResponse var trackInfo TidalTrack
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil {
return nil, err return nil, err
} }
return &result, nil fmt.Printf("Found: %s (%s)\n", trackInfo.Title, trackInfo.AudioQuality)
} return &trackInfo, nil
func (t *TidalDownloader) GetTrackInfo(query, isrc string) (*TidalTrack, error) {
fmt.Printf("Fetching: %s", query)
if isrc != "" {
fmt.Printf(" (ISRC: %s)", isrc)
}
fmt.Println()
result, err := t.SearchTracks(query)
if err != nil {
return nil, err
}
if len(result.Items) == 0 {
return nil, fmt.Errorf("no tracks found for query: %s", query)
}
var selectedTrack *TidalTrack
if isrc != "" {
var isrcMatches []TidalTrack
for _, item := range result.Items {
if item.ISRC == isrc {
isrcMatches = append(isrcMatches, item)
}
}
if len(isrcMatches) > 1 {
for _, item := range isrcMatches {
for _, tag := range item.MediaMetadata.Tags {
if tag == "HIRES_LOSSLESS" {
selectedTrack = &item
break
}
}
if selectedTrack != nil {
break
}
}
if selectedTrack == nil {
selectedTrack = &isrcMatches[0]
}
} else if len(isrcMatches) == 1 {
selectedTrack = &isrcMatches[0]
} else {
selectedTrack = &result.Items[0]
}
} else {
selectedTrack = &result.Items[0]
}
if selectedTrack == nil {
return nil, fmt.Errorf("track not found")
}
fmt.Printf("Found: %s (%s)\n", selectedTrack.Title, selectedTrack.AudioQuality)
return selectedTrack, nil
} }
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
fmt.Println("Fetching URL...") fmt.Println("Fetching URL...")
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url)
resp, err := t.client.Get(url) resp, err := t.client.Get(url)
if err != nil { if err != nil {
fmt.Printf("✗ Tidal API request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err) return "", fmt.Errorf("failed to get download URL: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode)
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
} }
var apiResponses []TidalAPIResponse var apiResponses []TidalAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil { if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err)
return "", fmt.Errorf("failed to decode response: %w", err) return "", fmt.Errorf("failed to decode response: %w", err)
} }
if len(apiResponses) == 0 { if len(apiResponses) == 0 {
fmt.Println("✗ Tidal API returned empty response")
return "", fmt.Errorf("no download URL in response") return "", fmt.Errorf("no download URL in response")
} }
for _, item := range apiResponses { for _, item := range apiResponses {
if item.OriginalTrackURL != "" { if item.OriginalTrackURL != "" {
fmt.Println("URL found") fmt.Println("✓ Tidal download URL found")
return item.OriginalTrackURL, nil return item.OriginalTrackURL, nil
} }
} }
fmt.Println("✗ No valid download URL in Tidal API response")
return "", fmt.Errorf("download URL not found in response") return "", fmt.Errorf("download URL not found in response")
} }
@@ -333,14 +361,23 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return nil return nil
} }
func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err) return "", fmt.Errorf("directory error: %w", err)
} }
} }
trackInfo, err := t.GetTrackInfo(query, isrc) 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 { if err != nil {
return "", err return "", err
} }
@@ -385,6 +422,12 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
albumTitle = trackInfo.Album.Title albumTitle = trackInfo.Album.Title
} }
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings // Build filename based on format settings
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
@@ -454,22 +497,68 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
} }
fmt.Println("Done") fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal")
return outputFilename, nil return outputFilename, nil
} }
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
apis, err := t.GetAvailableAPIs() apis, err := t.GetAvailableAPIs()
if err != nil { if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err) return "", fmt.Errorf("no APIs available for fallback: %w", err)
} }
var lastError error
for i, apiURL := range apis {
fmt.Printf("[Tidal API %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
fallbackDownloader := NewTidalDownloader(apiURL)
result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
if err == nil {
fmt.Printf("✓ Success with: %s\n", apiURL)
return result, nil
}
lastError = err
errMsg := err.Error()
if len(errMsg) > 80 {
errMsg = errMsg[:80]
}
fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg)
}
return "", fmt.Errorf("all %d Tidal APIs failed. Last error: %v", len(apis), lastError)
}
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Tidal URL from Spotify track ID
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
apis, err := t.GetAvailableAPIs()
if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err)
}
// Get Tidal URL once
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
var lastError error var lastError error
for i, apiURL := range apis { for i, apiURL := range apis {
fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL) fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
fallbackDownloader := NewTidalDownloader(apiURL) fallbackDownloader := NewTidalDownloader(apiURL)
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
if err == nil { if err == nil {
fmt.Printf("✓ Success with: %s\n", apiURL) fmt.Printf("✓ Success with: %s\n", apiURL)
return result, nil return result, nil
+6 -2
View File
@@ -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.1";
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;
@@ -144,6 +144,7 @@ function App() {
isDownloading={download.isDownloading} isDownloading={download.isDownloading}
downloadingTrack={download.downloadingTrack} downloadingTrack={download.downloadingTrack}
isDownloaded={download.downloadedTracks.has(track.isrc)} isDownloaded={download.downloadedTracks.has(track.isrc)}
isFailed={download.failedTracks.has(track.isrc)}
onDownload={download.handleDownloadTrack} onDownload={download.handleDownloadTrack}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
/> />
@@ -160,6 +161,7 @@ function App() {
sortBy={sortBy} sortBy={sortBy}
selectedTracks={selectedTracks} selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks} downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
downloadingTrack={download.downloadingTrack} downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading} isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType} bulkDownloadType={download.bulkDownloadType}
@@ -193,6 +195,7 @@ function App() {
sortBy={sortBy} sortBy={sortBy}
selectedTracks={selectedTracks} selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks} downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
downloadingTrack={download.downloadingTrack} downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading} isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType} bulkDownloadType={download.bulkDownloadType}
@@ -231,6 +234,7 @@ function App() {
sortBy={sortBy} sortBy={sortBy}
selectedTracks={selectedTracks} selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks} downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
downloadingTrack={download.downloadingTrack} downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading} isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType} bulkDownloadType={download.bulkDownloadType}
+3
View File
@@ -20,6 +20,7 @@ interface AlbumInfoProps {
sortBy: string; sortBy: string;
selectedTracks: string[]; selectedTracks: string[];
downloadedTracks: Set<string>; downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloading: boolean; isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null; bulkDownloadType: "all" | "selected" | null;
@@ -46,6 +47,7 @@ export function AlbumInfo({
sortBy, sortBy,
selectedTracks, selectedTracks,
downloadedTracks, downloadedTracks,
failedTracks,
downloadingTrack, downloadingTrack,
isDownloading, isDownloading,
bulkDownloadType, bulkDownloadType,
@@ -145,6 +147,7 @@ export function AlbumInfo({
sortBy={sortBy} sortBy={sortBy}
selectedTracks={selectedTracks} selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks} downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
downloadingTrack={downloadingTrack} downloadingTrack={downloadingTrack}
isDownloading={isDownloading} isDownloading={isDownloading}
currentPage={currentPage} currentPage={currentPage}
+3
View File
@@ -27,6 +27,7 @@ interface ArtistInfoProps {
sortBy: string; sortBy: string;
selectedTracks: string[]; selectedTracks: string[];
downloadedTracks: Set<string>; downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloading: boolean; isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null; bulkDownloadType: "all" | "selected" | null;
@@ -55,6 +56,7 @@ export function ArtistInfo({
sortBy, sortBy,
selectedTracks, selectedTracks,
downloadedTracks, downloadedTracks,
failedTracks,
downloadingTrack, downloadingTrack,
isDownloading, isDownloading,
bulkDownloadType, bulkDownloadType,
@@ -197,6 +199,7 @@ export function ArtistInfo({
sortBy={sortBy} sortBy={sortBy}
selectedTracks={selectedTracks} selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks} downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
downloadingTrack={downloadingTrack} downloadingTrack={downloadingTrack}
isDownloading={isDownloading} isDownloading={isDownloading}
currentPage={currentPage} currentPage={currentPage}
+1 -1
View File
@@ -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">
+3
View File
@@ -26,6 +26,7 @@ interface PlaylistInfoProps {
sortBy: string; sortBy: string;
selectedTracks: string[]; selectedTracks: string[];
downloadedTracks: Set<string>; downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloading: boolean; isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null; bulkDownloadType: "all" | "selected" | null;
@@ -52,6 +53,7 @@ export function PlaylistInfo({
sortBy, sortBy,
selectedTracks, selectedTracks,
downloadedTracks, downloadedTracks,
failedTracks,
downloadingTrack, downloadingTrack,
isDownloading, isDownloading,
bulkDownloadType, bulkDownloadType,
@@ -151,6 +153,7 @@ export function PlaylistInfo({
sortBy={sortBy} sortBy={sortBy}
selectedTracks={selectedTracks} selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks} downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
downloadingTrack={downloadingTrack} downloadingTrack={downloadingTrack}
isDownloading={isDownloading} isDownloading={isDownloading}
currentPage={currentPage} currentPage={currentPage}
+46 -51
View File
@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Search, Info, XCircle } from "lucide-react"; import { Search, Info, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -19,56 +18,52 @@ interface SearchBarProps {
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) { export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
return ( return (
<Card> <div className="space-y-2">
<CardContent className="px-6 py-6 space-y-4"> <div className="flex items-center gap-2">
<div className="space-y-2"> <Label htmlFor="spotify-url">Spotify URL</Label>
<div className="flex items-center gap-2"> <Tooltip>
<Label htmlFor="spotify-url">Spotify URL</Label> <TooltipTrigger asChild>
<Tooltip> <Info className="h-4 w-4 text-muted-foreground cursor-help" />
<TooltipTrigger asChild> </TooltipTrigger>
<Info className="h-4 w-4 text-muted-foreground cursor-help" /> <TooltipContent side="right">
</TooltipTrigger> <p>Supports track, album, playlist, and artist URLs</p>
<TooltipContent side="right"> </TooltipContent>
<p>Supports track, album, playlist, and artist URLs</p> </Tooltip>
</TooltipContent> </div>
</Tooltip> <div className="flex gap-2">
</div> <div className="relative flex-1">
<div className="flex gap-2"> <InputWithContext
<div className="relative flex-1"> id="spotify-url"
<InputWithContext placeholder="https://open.spotify.com/..."
id="spotify-url" value={url}
placeholder="https://open.spotify.com/..." onChange={(e) => onUrlChange(e.target.value)}
value={url} onKeyDown={(e) => e.key === "Enter" && onFetch()}
onChange={(e) => onUrlChange(e.target.value)} className="pr-8"
onKeyDown={(e) => e.key === "Enter" && onFetch()} />
className="pr-8" {url && (
/> <button
{url && ( type="button"
<button className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
type="button" onClick={() => onUrlChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" >
onClick={() => onUrlChange("")} <XCircle className="h-4 w-4" />
> </button>
<XCircle className="h-4 w-4" /> )}
</button>
)}
</div>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<Search className="h-4 w-4" />
Fetch
</>
)}
</Button>
</div>
</div> </div>
</CardContent> <Button onClick={onFetch} disabled={loading}>
</Card> {loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<Search className="h-4 w-4" />
Fetch
</>
)}
</Button>
</div>
</div>
); );
} }
+14 -1
View File
@@ -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>
+1 -1
View File
@@ -27,7 +27,7 @@ export function TitleBar() {
/> />
{/* Window control buttons */} {/* Window control buttons */}
<div className="absolute top-4 left-4 z-50 flex gap-2"> <div className="fixed top-4 left-4 z-50 flex gap-2">
<button <button
onClick={handleClose} onClick={handleClose}
onMouseEnter={() => setHoveredButton("close")} onMouseEnter={() => setHoveredButton("close")}
+14 -4
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
@@ -9,7 +9,8 @@ interface TrackInfoProps {
isDownloading: boolean; isDownloading: boolean;
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloaded: boolean; isDownloaded: boolean;
onDownload: (isrc: string, name: string, artists: string) => void; isFailed: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onOpenFolder: () => void; onOpenFolder: () => void;
} }
@@ -18,6 +19,7 @@ export function TrackInfo({
isDownloading, isDownloading,
downloadingTrack, downloadingTrack,
isDownloaded, isDownloaded,
isFailed,
onDownload, onDownload,
onOpenFolder, onOpenFolder,
}: TrackInfoProps) { }: TrackInfoProps) {
@@ -34,7 +36,15 @@ export function TrackInfo({
)} )}
<div className="flex-1 space-y-4 min-w-0"> <div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isDownloaded && (
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
)}
{isFailed && (
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
)}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p> <p className="text-lg text-muted-foreground">{track.artists}</p>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
@@ -50,7 +60,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 ? (
+6 -1
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle } from "lucide-react"; import { Download, CheckCircle, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
Pagination, Pagination,
@@ -18,6 +18,7 @@ interface TrackListProps {
sortBy: string; sortBy: string;
selectedTracks: string[]; selectedTracks: string[];
downloadedTracks: Set<string>; downloadedTracks: Set<string>;
failedTracks: Set<string>;
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloading: boolean; isDownloading: boolean;
currentPage: number; currentPage: number;
@@ -36,6 +37,7 @@ export function TrackList({
sortBy, sortBy,
selectedTracks, selectedTracks,
downloadedTracks, downloadedTracks,
failedTracks,
downloadingTrack, downloadingTrack,
isDownloading, isDownloading,
currentPage, currentPage,
@@ -165,6 +167,9 @@ export function TrackList({
{downloadedTracks.has(track.isrc) && ( {downloadedTracks.has(track.isrc) && (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
)} )}
{failedTracks.has(track.isrc) && (
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
)}
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{track.artists} {track.artists}
+159 -50
View File
@@ -11,6 +11,7 @@ export function useDownload() {
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null); const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null); const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set()); const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
const [failedTracks, setFailedTracks] = useState<Set<string>>(new Set());
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{ const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
name: string; name: string;
artists: string; artists: string;
@@ -25,7 +26,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;
@@ -56,50 +58,100 @@ export function useDownload() {
} }
if (service === "auto") { if (service === "auto") {
// Try Tidal first // Get all streaming URLs once from song.link API
try { let streamingURLs: any = null;
const tidalResponse = await downloadTrack({ if (spotifyId) {
isrc, try {
service: "tidal", const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
query, const urlsJson = await GetStreamingURLs(spotifyId);
track_name: trackName, streamingURLs = JSON.parse(urlsJson);
artist_name: artistName, } catch (err) {
album_name: albumName, console.error("Failed to get streaming URLs:", err);
output_dir: outputDir, }
filename_format: settings.filenameFormat, }
track_number: settings.trackNumber,
position, // Try Tidal first
use_album_track_number: useAlbumTrackNumber, if (streamingURLs?.tidal_url) {
}); try {
const tidalResponse = await downloadTrack({
if (tidalResponse.success) { isrc,
return tidalResponse; service: "tidal",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.tidal_url,
});
if (tidalResponse.success) {
return tidalResponse;
}
console.log("Tidal failed, trying Deezer...");
} catch (tidalErr) {
console.log("Tidal error:", tidalErr);
} }
} catch (tidalErr) {
// Tidal failed, continue to Deezer
} }
// Try Deezer second // Try Deezer second
try { if (streamingURLs?.deezer_url) {
const deezerResponse = await downloadTrack({ try {
isrc, const deezerResponse = await downloadTrack({
service: "deezer", isrc,
query, service: "deezer",
track_name: trackName, query,
artist_name: artistName, track_name: trackName,
album_name: albumName, artist_name: artistName,
output_dir: outputDir, album_name: albumName,
filename_format: settings.filenameFormat, output_dir: outputDir,
track_number: settings.trackNumber, filename_format: settings.filenameFormat,
position, track_number: settings.trackNumber,
use_album_track_number: useAlbumTrackNumber, position,
}); use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
});
if (deezerResponse.success) { if (deezerResponse.success) {
return deezerResponse; return deezerResponse;
}
console.log("Deezer failed, trying Amazon...");
} catch (deezerErr) {
console.log("Deezer error:", deezerErr);
}
}
// Try Amazon third
if (streamingURLs?.amazon_url) {
try {
const amazonResponse = await downloadTrack({
isrc,
service: "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.amazon_url,
});
if (amazonResponse.success) {
return amazonResponse;
}
console.log("Amazon failed, trying Qobuz...");
} catch (amazonErr) {
console.log("Amazon error:", amazonErr);
} }
} catch (deezerErr) {
// Deezer failed, continue to Qobuz
} }
// Try Qobuz as last fallback // Try Qobuz as last fallback
@@ -108,7 +160,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 +170,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 +178,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 +199,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) {
@@ -183,6 +238,7 @@ export function useDownload() {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0;
const total = selectedTracks.length; const total = selectedTracks.length;
for (let i = 0; i < selectedTracks.length; i++) { for (let i = 0; i < selectedTracks.length; i++) {
@@ -212,17 +268,30 @@ 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) {
successCount++; if (response.already_exists) {
skippedCount++;
console.log(`Skipped: ${track?.name} - ${track?.artists} (already exists)`);
} else {
successCount++;
}
setDownloadedTracks((prev) => new Set(prev).add(isrc)); setDownloadedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(isrc); // Remove from failed if it was there
return newSet;
});
} else { } else {
errorCount++; errorCount++;
setFailedTracks((prev) => new Set(prev).add(isrc));
} }
} catch (err) { } catch (err) {
errorCount++; errorCount++;
setFailedTracks((prev) => new Set(prev).add(isrc));
} }
setDownloadProgress(Math.round(((i + 1) / total) * 100)); setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -234,10 +303,22 @@ export function useDownload() {
setBulkDownloadType(null); setBulkDownloadType(null);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
if (errorCount === 0) { // Build summary message
if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`); toast.success(`Downloaded ${successCount} tracks successfully`);
} else if (errorCount === 0 && successCount === 0) {
// All skipped
toast.info(`${skippedCount} tracks already exist`);
} else if (errorCount === 0) {
// Mix of downloaded and skipped
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
} else { } else {
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); // Has errors
const parts = [];
if (successCount > 0) parts.push(`${successCount} downloaded`);
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
parts.push(`${errorCount} failed`);
toast.warning(parts.join(", "));
} }
}; };
@@ -260,6 +341,7 @@ export function useDownload() {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0;
const total = tracksWithIsrc.length; const total = tracksWithIsrc.length;
for (let i = 0; i < tracksWithIsrc.length; i++) { for (let i = 0; i < tracksWithIsrc.length; i++) {
@@ -284,17 +366,30 @@ 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) {
successCount++; if (response.already_exists) {
skippedCount++;
console.log(`Skipped: ${track.name} - ${track.artists} (already exists)`);
} else {
successCount++;
}
setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(track.isrc); // Remove from failed if it was there
return newSet;
});
} else { } else {
errorCount++; errorCount++;
setFailedTracks((prev) => new Set(prev).add(track.isrc));
} }
} catch (err) { } catch (err) {
errorCount++; errorCount++;
setFailedTracks((prev) => new Set(prev).add(track.isrc));
} }
setDownloadProgress(Math.round(((i + 1) / total) * 100)); setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -306,10 +401,22 @@ export function useDownload() {
setBulkDownloadType(null); setBulkDownloadType(null);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
if (errorCount === 0) { // Build summary message
if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`); toast.success(`Downloaded ${successCount} tracks successfully`);
} else if (errorCount === 0 && successCount === 0) {
// All skipped
toast.info(`${skippedCount} tracks already exist`);
} else if (errorCount === 0) {
// Mix of downloaded and skipped
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
} else { } else {
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); // Has errors
const parts = [];
if (successCount > 0) parts.push(`${successCount} downloaded`);
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
parts.push(`${errorCount} failed`);
toast.warning(parts.join(", "));
} }
}; };
@@ -320,6 +427,7 @@ export function useDownload() {
const resetDownloadedTracks = () => { const resetDownloadedTracks = () => {
setDownloadedTracks(new Set()); setDownloadedTracks(new Set());
setFailedTracks(new Set());
}; };
return { return {
@@ -328,6 +436,7 @@ export function useDownload() {
downloadingTrack, downloadingTrack,
bulkDownloadType, bulkDownloadType,
downloadedTracks, downloadedTracks,
failedTracks,
currentDownloadInfo, currentDownloadInfo,
handleDownloadTrack, handleDownloadTrack,
handleDownloadSelected, handleDownloadSelected,
+1 -1
View File
@@ -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";
+4 -1
View File
@@ -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,8 @@ 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;
service_url?: string;
} }
export interface DownloadResponse { export interface DownloadResponse {
+10 -10
View File
@@ -12,29 +12,29 @@ require (
require ( require (
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/echo/v4 v4.13.4 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect github.com/samber/lo v1.52.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.31.0 // indirect
) )
+20 -22
View File
@@ -10,16 +10,16 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
@@ -35,9 +35,8 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -49,8 +48,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@@ -59,29 +58,28 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2 -3
View File
@@ -6,6 +6,5 @@
"eu-katze.qqdl.site", "eu-katze.qqdl.site",
"katze.qqdl.site", "katze.qqdl.site",
"wolf.qqdl.site", "wolf.qqdl.site",
"tidal.kinoplus.online", "tidal.kinoplus.online"
"jakarta.monochrome.tf" ]
]
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "5.8" "version": "6.0"
} }
+1 -1
View File
@@ -11,7 +11,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "5.9" "productVersion": "6.1"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",