v5.7-beta3
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-9
@@ -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,7 +196,13 @@ 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
|
||||||
|
artists := spotifyArtistName
|
||||||
|
trackTitle := spotifyTrackName
|
||||||
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
|
if artists == "" {
|
||||||
|
artists = track.Artist.Name
|
||||||
if len(track.Contributors) > 0 {
|
if len(track.Contributors) > 0 {
|
||||||
var mainArtists []string
|
var mainArtists []string
|
||||||
for _, contrib := range track.Contributors {
|
for _, contrib := range track.Contributors {
|
||||||
@@ -203,9 +214,18 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
|
|||||||
artists = strings.Join(mainArtists, ", ")
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+33
-12
@@ -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,6 +329,12 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
|
|||||||
return "", fmt.Errorf("no track ID found")
|
return "", fmt.Errorf("no track ID found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use Spotify metadata if provided, otherwise fallback to Tidal metadata
|
||||||
|
artistName := spotifyArtistName
|
||||||
|
trackTitle := spotifyTrackName
|
||||||
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
|
if artistName == "" {
|
||||||
var artists []string
|
var artists []string
|
||||||
if len(trackInfo.Artists) > 0 {
|
if len(trackInfo.Artists) > 0 {
|
||||||
for _, artist := range trackInfo.Artists {
|
for _, artist := range trackInfo.Artists {
|
||||||
@@ -333,16 +346,24 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
|
|||||||
artists = append(artists, trackInfo.Artist.Name)
|
artists = append(artists, trackInfo.Artist.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
artistName := "Unknown Artist"
|
artistName = "Unknown Artist"
|
||||||
if len(artists) > 0 {
|
if len(artists) > 0 {
|
||||||
artistName = strings.Join(artists, ", ")
|
artistName = strings.Join(artists, ", ")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
artistName = sanitizeFilename(artistName)
|
artistName = sanitizeFilename(artistName)
|
||||||
|
|
||||||
trackTitle := sanitizeFilename(trackInfo.Title)
|
if trackTitle == "" {
|
||||||
|
trackTitle = trackInfo.Title
|
||||||
if trackTitle == "" {
|
if trackTitle == "" {
|
||||||
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
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
|
||||||
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber)
|
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user