v5.7-beta3

This commit is contained in:
afkarxyz
2025-11-22 15:45:17 +07:00
parent ee2976143a
commit 8a2dbe4e32
8 changed files with 536 additions and 55 deletions
+12 -3
View File
@@ -37,6 +37,9 @@ type DownloadRequest struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Service string `json:"service"` Service string `json:"service"`
Query string `json:"query,omitempty"` Query string `json:"query,omitempty"`
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
AlbumName string `json:"album_name,omitempty"`
ApiURL string `json:"api_url,omitempty"` ApiURL string `json:"api_url,omitempty"`
OutputDir string `json:"output_dir,omitempty"` OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"` AudioFormat string `json:"audio_format,omitempty"`
@@ -118,14 +121,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
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) filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
} 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) filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
}
} else if req.Service == "qobuz" {
downloader := backend.NewQobuzDownloader()
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
if err == nil {
filename = "Downloaded via Qobuz"
} }
} else { } else {
downloader := backend.NewDeezerDownloader() downloader := backend.NewDeezerDownloader()
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber) err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
if err == nil { if err == nil {
filename = "Downloaded via Deezer" filename = "Downloaded via Deezer"
} }
+37 -17
View File
@@ -1,6 +1,7 @@
package backend package backend
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -57,7 +58,9 @@ func NewDeezerDownloader() *DeezerDownloader {
} }
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) { func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc) // Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=")
url := fmt.Sprintf("%s%s", string(apiBase), isrc)
resp, err := d.client.Get(url) resp, err := d.client.Get(url)
if err != nil { if err != nil {
@@ -82,7 +85,9 @@ func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
} }
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) { func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID) // Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
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 {
@@ -183,7 +188,7 @@ 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) error { func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) error {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc) fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
track, err := d.GetTrackByISRC(isrc) track, err := d.GetTrackByISRC(isrc)
@@ -191,21 +196,36 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
return err return err
} }
artists := track.Artist.Name // Use Spotify metadata if provided, otherwise fallback to Deezer metadata
if len(track.Contributors) > 0 { artists := spotifyArtistName
var mainArtists []string trackTitle := spotifyTrackName
for _, contrib := range track.Contributors { albumTitle := spotifyAlbumName
if contrib.Role == "Main" {
mainArtists = append(mainArtists, contrib.Name) if artists == "" {
artists = track.Artist.Name
if len(track.Contributors) > 0 {
var mainArtists []string
for _, contrib := range track.Contributors {
if contrib.Role == "Main" {
mainArtists = append(mainArtists, contrib.Name)
}
}
if len(mainArtists) > 0 {
artists = strings.Join(mainArtists, ", ")
} }
}
if len(mainArtists) > 0 {
artists = strings.Join(mainArtists, ", ")
} }
} }
fmt.Printf("Found track: %s - %s\n", artists, track.Title) if trackTitle == "" {
fmt.Printf("Album: %s\n", track.Album.Title) trackTitle = track.Title
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
downloadURL, err := d.GetDownloadURL(track.ID) downloadURL, err := d.GetDownloadURL(track.ID)
if err != nil { if err != nil {
@@ -213,7 +233,7 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
} }
safeArtist := sanitizeFilename(artists) safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(track.Title) safeTitle := sanitizeFilename(trackTitle)
// Build filename based on format settings // Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber) filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber)
@@ -239,9 +259,9 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
fmt.Println("Embedding metadata and cover art...") fmt.Println("Embedding metadata and cover art...")
metadata := Metadata{ metadata := Metadata{
Title: track.Title, Title: trackTitle,
Artist: artists, Artist: artists,
Album: track.Album.Title, Album: albumTitle,
Date: track.ReleaseDate, Date: track.ReleaseDate,
TrackNumber: track.TrackPos, TrackNumber: track.TrackPos,
DiscNumber: track.DiskNumber, DiscNumber: track.DiskNumber,
+355
View File
@@ -0,0 +1,355 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
type QobuzDownloader struct {
client *http.Client
appID string
}
type QobuzSearchResponse struct {
Query string `json:"query"`
Tracks struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
Duration int `json:"duration"`
TrackNumber int `json:"track_number"`
MediaNumber int `json:"media_number"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright"`
MaximumBitDepth int `json:"maximum_bit_depth"`
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
Hires bool `json:"hires"`
HiresStreamable bool `json:"hires_streamable"`
ReleaseDateOriginal string `json:"release_date_original"`
Performer struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"performer"`
Album struct {
Title string `json:"title"`
ID string `json:"id"`
Image struct {
Small string `json:"small"`
Thumbnail string `json:"thumbnail"`
Large string `json:"large"`
} `json:"image"`
Artist struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"artist"`
Label struct {
Name string `json:"name"`
} `json:"label"`
} `json:"album"`
}
type QobuzStreamResponse struct {
URL string `json:"url"`
}
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
appID: "798273057",
}
}
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
resp, err := q.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to search track: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var searchResp QobuzSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(searchResp.Tracks.Items) == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
}
return &searchResp.Tracks.Items[0], nil
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
// Map quality to Qobuz quality code
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
qualityCode := "27" // Default to Hi-Res
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
resp, err := q.client.Get(primaryURL)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Primary API response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
fmt.Printf("Got download URL from primary API\n")
return streamResp.URL, nil
}
}
if resp != nil {
resp.Body.Close()
}
// Fallback to secondary API
fmt.Println("Primary API failed, trying fallback...")
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
resp, err = q.client.Get(fallbackURL)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Fallback API error response: %s\n", string(body))
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Fallback API response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if streamResp.URL == "" {
return "", fmt.Errorf("no download URL available")
}
fmt.Printf("Got download URL from fallback API\n")
return streamResp.URL, nil
}
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
resp, err := q.client.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
fmt.Printf("Creating file: %s\n", filepath)
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
fmt.Println("Writing file content...")
written, err := io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("✓ Downloaded %d bytes\n", written)
return nil
}
func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
resp, err := q.client.Get(coverURL)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create cover file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool) string {
var filename string
// Build base filename based on format
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled
if includeTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
}
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) error {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
// Create output directory if it doesn't exist
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(isrc)
if err != nil {
return err
}
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Performer.Name
if track.Album.Artist.Name != "" {
artists = track.Album.Artist.Name
}
}
if trackTitle == "" {
trackTitle = track.Title
if track.Version != "" && track.Version != "null" {
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
}
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
qualityInfo := "Standard"
if track.Hires {
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
}
fmt.Printf("Quality: %s\n", qualityInfo)
fmt.Println("Getting download URL...")
downloadURL, err := q.GetDownloadURL(track.ID, quality)
if err != nil {
return fmt.Errorf("failed to get download URL: %w", err)
}
if downloadURL == "" {
return fmt.Errorf("received empty download URL")
}
// Show partial URL for security
urlPreview := downloadURL
if len(downloadURL) > 60 {
urlPreview = downloadURL[:60] + "..."
}
fmt.Printf("Download URL obtained: %s\n", urlPreview)
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
// Build filename based on format settings
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber)
filepath := filepath.Join(outputDir, filename)
fmt.Printf("Downloading FLAC file to: %s\n", filepath)
if err := q.DownloadFile(downloadURL, filepath); err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.Image.Large != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
} else {
defer os.Remove(coverPath)
}
}
fmt.Println("Embedding metadata and cover art...")
releaseYear := ""
if len(track.ReleaseDateOriginal) >= 4 {
releaseYear = track.ReleaseDateOriginal[:4]
}
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: releaseYear,
TrackNumber: track.TrackNumber,
DiscNumber: track.MediaNumber,
ISRC: track.ISRC,
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
return fmt.Errorf("failed to embed metadata: %w", err)
}
fmt.Println("Metadata embedded successfully!")
return nil
}
+46 -25
View File
@@ -81,7 +81,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
} }
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
resp, err := http.Get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/tidal.json") // Decode base64 API URL
apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==")
resp, err := http.Get(string(apiURL))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch API list: %w", err) return nil, fmt.Errorf("failed to fetch API list: %w", err)
} }
@@ -107,7 +109,9 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
func (t *TidalDownloader) GetAccessToken() (string, error) { func (t *TidalDownloader) GetAccessToken() (string, error) {
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
req, err := http.NewRequest("POST", "https://auth.tidal.com/v1/oauth2/token", strings.NewReader(data)) // Decode base64 API URL
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -142,8 +146,9 @@ func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, erro
return nil, fmt.Errorf("failed to get access token: %w", err) return nil, fmt.Errorf("failed to get access token: %w", err)
} }
// URL encode the query parameter // Decode base64 API URL and encode the query parameter
searchURL := fmt.Sprintf("https://api.tidal.com/v1/search/tracks?query=%s&limit=25&offset=0&countryCode=US", url.QueryEscape(query)) searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=25&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query))
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", searchURL, nil)
if err != nil { if err != nil {
@@ -265,7 +270,9 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) { func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
albumID = strings.ReplaceAll(albumID, "-", "/") albumID = strings.ReplaceAll(albumID, "-", "/")
artURL := fmt.Sprintf("https://resources.tidal.com/images/%s/1280x1280.jpg", albumID) // Decode base64 API URL
imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=")
artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID)
resp, err := t.client.Get(artURL) resp, err := t.client.Get(artURL)
if err != nil { if err != nil {
@@ -306,7 +313,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return nil return nil
} }
func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) { func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) (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)
@@ -322,26 +329,40 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
return "", fmt.Errorf("no track ID found") return "", fmt.Errorf("no track ID found")
} }
var artists []string // Use Spotify metadata if provided, otherwise fallback to Tidal metadata
if len(trackInfo.Artists) > 0 { artistName := spotifyArtistName
for _, artist := range trackInfo.Artists { trackTitle := spotifyTrackName
if artist.Name != "" { albumTitle := spotifyAlbumName
artists = append(artists, artist.Name)
}
}
} else if trackInfo.Artist.Name != "" {
artists = append(artists, trackInfo.Artist.Name)
}
artistName := "Unknown Artist" if artistName == "" {
if len(artists) > 0 { var artists []string
artistName = strings.Join(artists, ", ") if len(trackInfo.Artists) > 0 {
for _, artist := range trackInfo.Artists {
if artist.Name != "" {
artists = append(artists, artist.Name)
}
}
} else if trackInfo.Artist.Name != "" {
artists = append(artists, trackInfo.Artist.Name)
}
artistName = "Unknown Artist"
if len(artists) > 0 {
artistName = strings.Join(artists, ", ")
}
} }
artistName = sanitizeFilename(artistName) artistName = sanitizeFilename(artistName)
trackTitle := sanitizeFilename(trackInfo.Title)
if trackTitle == "" { if trackTitle == "" {
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID) trackTitle = trackInfo.Title
if trackTitle == "" {
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
}
}
trackTitle = sanitizeFilename(trackTitle)
if albumTitle == "" {
albumTitle = trackInfo.Album.Title
} }
// Build filename based on format settings // Build filename based on format settings
@@ -387,9 +408,9 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
} }
metadata := Metadata{ metadata := Metadata{
Title: trackInfo.Title, Title: trackTitle,
Artist: artistName, Artist: artistName,
Album: trackInfo.Album.Title, Album: albumTitle,
Date: releaseYear, Date: releaseYear,
TrackNumber: trackInfo.TrackNumber, TrackNumber: trackInfo.TrackNumber,
DiscNumber: trackInfo.VolumeNumber, DiscNumber: trackInfo.VolumeNumber,
@@ -406,7 +427,7 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
return outputFilename, nil return outputFilename, nil
} }
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) { func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) (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)
@@ -418,7 +439,7 @@ func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality,
fallbackDownloader := NewTidalDownloader(apiURL) fallbackDownloader := NewTidalDownloader(apiURL)
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber) result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
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
+48 -4
View File
@@ -24,6 +24,27 @@ import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSetti
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
const DeezerIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></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());
@@ -134,7 +155,7 @@ export function Settings() {
setTempSettings((prev) => ({ ...prev, downloadPath: value })); setTempSettings((prev) => ({ ...prev, downloadPath: value }));
}; };
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal") => { const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz") => {
setTempSettings((prev) => ({ ...prev, downloader: value })); setTempSettings((prev) => ({ ...prev, downloader: value }));
}; };
@@ -203,9 +224,32 @@ export function Settings() {
<SelectValue placeholder="Select a source" /> <SelectValue placeholder="Select a source" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto (Tidal Deezer)</SelectItem> <SelectItem value="auto">
<SelectItem value="tidal">Tidal</SelectItem> <span className="flex items-center">
<SelectItem value="deezer">Deezer</SelectItem> <TidalIcon />
<DeezerIcon />
<QobuzIcon />
Auto (Tidal Deezer Qobuz)
</span>
</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="deezer">
<span className="flex items-center">
<DeezerIcon />
Deezer
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
+33 -4
View File
@@ -52,11 +52,15 @@ export function useDownload() {
} }
if (service === "auto") { if (service === "auto") {
// Try Tidal first
try { try {
const tidalResponse = await downloadTrack({ const tidalResponse = await downloadTrack({
isrc, isrc,
service: "tidal", service: "tidal",
query, query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameFormat,
track_number: settings.trackNumber, track_number: settings.trackNumber,
@@ -65,17 +69,42 @@ export function useDownload() {
if (tidalResponse.success) { if (tidalResponse.success) {
return tidalResponse; return tidalResponse;
} }
service = "deezer";
} catch (tidalErr) { } catch (tidalErr) {
service = "deezer"; // Tidal failed, continue to Deezer
} }
// Try Deezer second
try {
const deezerResponse = await downloadTrack({
isrc,
service: "deezer",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
});
if (deezerResponse.success) {
return deezerResponse;
}
} catch (deezerErr) {
// Deezer failed, continue to Qobuz
}
// Try Qobuz as last fallback
service = "qobuz";
} }
return await downloadTrack({ return await downloadTrack({
isrc, isrc,
service: service as "deezer" | "tidal", service: service as "deezer" | "tidal" | "qobuz",
query, query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameFormat,
track_number: settings.trackNumber, track_number: settings.trackNumber,
+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"; downloader: "auto" | "deezer" | "tidal" | "qobuz";
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
@@ -97,8 +97,11 @@ export type SpotifyMetadataResponse =
export interface DownloadRequest { export interface DownloadRequest {
isrc: string; isrc: string;
service: "deezer" | "tidal"; service: "deezer" | "tidal" | "qobuz";
query?: string; query?: string;
track_name?: string;
artist_name?: string;
album_name?: string;
api_url?: string; api_url?: string;
output_dir?: string; output_dir?: string;
audio_format?: string; audio_format?: string;