Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee3c2f653 | |||
| 73d8205f6f |
@@ -50,7 +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 for Amazon Music
|
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
|
||||||
@@ -62,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 == "" {
|
||||||
@@ -120,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)
|
||||||
@@ -139,34 +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 == "amazon" {
|
switch req.Service {
|
||||||
|
case "amazon":
|
||||||
|
downloader := backend.NewAmazonDownloader()
|
||||||
|
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 == "" {
|
if req.SpotifyID == "" {
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: "Spotify ID is required for Amazon Music",
|
Error: "Spotify ID is required for Amazon Music",
|
||||||
}, fmt.Errorf("Spotify ID is required for Amazon Music")
|
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
||||||
}
|
}
|
||||||
downloader := backend.NewAmazonDownloader()
|
|
||||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||||
} else if req.Service == "tidal" {
|
|
||||||
searchQuery := req.Query
|
|
||||||
if searchQuery == "" {
|
|
||||||
searchQuery = req.ISRC
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
} else if req.Service == "qobuz" {
|
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
+23
-5
@@ -356,7 +356,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
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
|
// Create output directory if needed
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
@@ -364,11 +364,18 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filena
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from Spotify track ID
|
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
|
||||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||||
if err != nil {
|
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||||
return "", err
|
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
|
// Download from service
|
||||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||||
@@ -410,5 +417,16 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filena
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Done")
|
fmt.Println("Done")
|
||||||
|
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
||||||
return filePath, nil
|
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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -271,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
|
||||||
@@ -321,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) {
|
||||||
@@ -341,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
|
||||||
|
|||||||
+168
-79
@@ -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
|
||||||
|
|||||||
@@ -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 = "6.0";
|
const CURRENT_VERSION = "6.1";
|
||||||
|
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,8 +18,6 @@ interface SearchBarProps {
|
|||||||
|
|
||||||
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
|
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
|
||||||
<CardContent className="px-6 py-6 space-y-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="spotify-url">Spotify URL</Label>
|
<Label htmlFor="spotify-url">Spotify URL</Label>
|
||||||
@@ -68,7 +65,5 @@ export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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,6 +9,7 @@ interface TrackInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloaded: boolean;
|
isDownloaded: boolean;
|
||||||
|
isFailed: boolean;
|
||||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
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">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
<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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -57,7 +58,20 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
|
// Get all streaming URLs once from song.link API
|
||||||
|
let streamingURLs: any = null;
|
||||||
|
if (spotifyId) {
|
||||||
|
try {
|
||||||
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
|
const urlsJson = await GetStreamingURLs(spotifyId);
|
||||||
|
streamingURLs = JSON.parse(urlsJson);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to get streaming URLs:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try Tidal first
|
// Try Tidal first
|
||||||
|
if (streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
const tidalResponse = await downloadTrack({
|
const tidalResponse = await downloadTrack({
|
||||||
isrc,
|
isrc,
|
||||||
@@ -71,16 +85,21 @@ 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,
|
||||||
|
service_url: streamingURLs.tidal_url,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tidalResponse.success) {
|
if (tidalResponse.success) {
|
||||||
return tidalResponse;
|
return tidalResponse;
|
||||||
}
|
}
|
||||||
|
console.log("Tidal failed, trying Deezer...");
|
||||||
} catch (tidalErr) {
|
} catch (tidalErr) {
|
||||||
// Tidal failed, continue to Deezer
|
console.log("Tidal error:", tidalErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Deezer second
|
// Try Deezer second
|
||||||
|
if (streamingURLs?.deezer_url) {
|
||||||
try {
|
try {
|
||||||
const deezerResponse = await downloadTrack({
|
const deezerResponse = await downloadTrack({
|
||||||
isrc,
|
isrc,
|
||||||
@@ -94,13 +113,45 @@ 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,
|
||||||
|
service_url: streamingURLs.deezer_url,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deezerResponse.success) {
|
if (deezerResponse.success) {
|
||||||
return deezerResponse;
|
return deezerResponse;
|
||||||
}
|
}
|
||||||
|
console.log("Deezer failed, trying Amazon...");
|
||||||
} catch (deezerErr) {
|
} catch (deezerErr) {
|
||||||
// Deezer failed, continue to Qobuz
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Qobuz as last fallback
|
// Try Qobuz as last fallback
|
||||||
@@ -187,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++) {
|
||||||
@@ -221,13 +273,25 @@ export function useDownload() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
skippedCount++;
|
||||||
|
console.log(`Skipped: ${track?.name} - ${track?.artists} (already exists)`);
|
||||||
|
} else {
|
||||||
successCount++;
|
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));
|
||||||
@@ -239,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(", "));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,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++) {
|
||||||
@@ -294,13 +371,25 @@ export function useDownload() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
skippedCount++;
|
||||||
|
console.log(`Skipped: ${track.name} - ${track.artists} (already exists)`);
|
||||||
|
} else {
|
||||||
successCount++;
|
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));
|
||||||
@@ -312,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(", "));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,6 +427,7 @@ export function useDownload() {
|
|||||||
|
|
||||||
const resetDownloadedTracks = () => {
|
const resetDownloadedTracks = () => {
|
||||||
setDownloadedTracks(new Set());
|
setDownloadedTracks(new Set());
|
||||||
|
setFailedTracks(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -334,6 +436,7 @@ export function useDownload() {
|
|||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
|
failedTracks,
|
||||||
currentDownloadInfo,
|
currentDownloadInfo,
|
||||||
handleDownloadTrack,
|
handleDownloadTrack,
|
||||||
handleDownloadSelected,
|
handleDownloadSelected,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface DownloadRequest {
|
|||||||
position?: number;
|
position?: number;
|
||||||
use_album_track_number?: boolean;
|
use_album_track_number?: boolean;
|
||||||
spotify_id?: string;
|
spotify_id?: string;
|
||||||
|
service_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadResponse {
|
export interface DownloadResponse {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
+1
-2
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "5.9"
|
"version": "6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "6.0"
|
"productVersion": "6.1"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user