diff --git a/README.md b/README.md index 28b826f..b6259c1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required. +Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required. ![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=) ![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white) @@ -94,7 +94,7 @@ The software is provided "as is", without warranty of any kind. The author assum ## API Credits -[MusicBrainz](https://musicbrainz.org) · [Spotify Lyrics API](https://github.akashrchandran.in/spotify-lyrics-api) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) +[MusicBrainz](https://musicbrainz.org) · [Spotify Lyrics API](https://github.akashrchandran.in/spotify-lyrics-api) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) · [yoinkify.lol](https://yoinkify.lol) > [!TIP] > diff --git a/app.go b/app.go index a95ad92..fe534bf 100644 --- a/app.go +++ b/app.go @@ -90,6 +90,7 @@ type DownloadRequest struct { AllowFallback bool `json:"allow_fallback"` UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"` UseSingleGenre bool `json:"use_single_genre,omitempty"` + EmbedGenre bool `json:"embed_genre,omitempty"` } type DownloadResponse struct { @@ -368,25 +369,25 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre) + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } case "tidal": if req.ApiURL == "" || req.ApiURL == "auto" { downloader := backend.NewTidalDownloader("") if req.ServiceURL != "" { - filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) + filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } } else { downloader := backend.NewTidalDownloader(req.ApiURL) if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } } @@ -399,7 +400,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if quality == "" { quality = "6" } - filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre) + filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + + case "deezer": + downloader := backend.NewDeezerDownloader() + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) default: return DownloadResponse{ @@ -480,11 +485,17 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { meta, err := backend.GetTrackMetadata(fPath) if err == nil && meta != nil { - quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0) + if meta.BitsPerSample > 0 { + quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0) + } else if meta.Bitrate > 0 { + quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0) + } else if meta.SampleRate > 0 { + quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0) + } d := int(meta.Duration) durationStr = fmt.Sprintf("%d:%02d", d/60, d%60) } else { - + fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err) } item := backend.HistoryItem{ @@ -495,7 +506,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { DurationStr: durationStr, CoverURL: cover, Quality: quality, - Format: format, + Format: strings.ToUpper(format), Path: fPath, } diff --git a/backend/amazon.go b/backend/amazon.go index ae0a57b..9acaaa6 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -111,7 +111,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) } - apiURL := fmt.Sprintf("https://amz.afkarxyz.fun/api/track/%s", asin) + apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return "", err @@ -259,7 +259,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) } -func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -289,7 +289,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } metaChan := make(chan mbResult, 1) - if spotifyURL != "" { + if embedGenre && spotifyURL != "" { go func() { res := mbResult{} var isrc string @@ -306,7 +306,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename res.ISRC = isrc if isrc != "" { fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre); err == nil { + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { res.Metadata = fetchedMeta fmt.Println("✓ MusicBrainz metadata fetched") } else { @@ -363,6 +363,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum) newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist) newFilename = strings.ReplaceAll(newFilename, "{year}", year) + newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate)) if spotifyDiscNumber > 0 { newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber)) @@ -472,7 +473,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, - useFirstArtistOnly bool, useSingleGenre bool, + useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool, ) (string, error) { amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) @@ -480,5 +481,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit return "", err } - return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre) + return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre) } diff --git a/backend/analysis.go b/backend/analysis.go index 209a232..a15ca78 100644 --- a/backend/analysis.go +++ b/backend/analysis.go @@ -4,6 +4,10 @@ import ( "fmt" "math" "os" + "os/exec" + "strconv" + "strings" + "time" "github.com/go-flac/go-flac" mewflac "github.com/mewkiz/flac" @@ -17,6 +21,7 @@ type AnalysisResult struct { BitsPerSample uint8 `json:"bits_per_sample"` TotalSamples uint64 `json:"total_samples"` Duration float64 `json:"duration"` + Bitrate int `json:"bit_rate"` BitDepth string `json:"bit_depth"` DynamicRange float64 `json:"dynamic_range"` PeakAmplitude float64 `json:"peak_amplitude"` @@ -168,40 +173,106 @@ func GetTrackMetadata(filepath string) (*AnalysisResult, error) { return nil, fmt.Errorf("file does not exist: %s", filepath) } - fileInfo, err := os.Stat(filepath) + return GetMetadataWithFFprobe(filepath) +} + +func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) { + ffprobePath, err := GetFFprobePath() if err != nil { - return nil, fmt.Errorf("failed to get file info: %w", err) + return nil, err } - f, err := flac.ParseFile(filepath) + for i := 0; i < 5; i++ { + if f, err := os.Open(filePath); err == nil { + f.Close() + break + } + time.Sleep(200 * time.Millisecond) + } + + args := []string{ + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate", + "-of", "default=noprint_wrappers=1:nokey=1", + filePath, + } + + cmd := exec.Command(ffprobePath, args...) + setHideWindow(cmd) + output, err := cmd.CombinedOutput() if err != nil { - return nil, fmt.Errorf("failed to parse FLAC file: %w", err) + return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output)) } - result := &AnalysisResult{ - FilePath: filepath, - FileSize: fileInfo.Size(), + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) < 4 { + return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output)) } - if len(f.Meta) > 0 { - streamInfo := f.Meta[0] - if streamInfo.Type == flac.StreamInfo { - data := streamInfo.Data - if len(data) >= 18 { - result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 - result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1 - result.TotalSamples = uint64(data[13]&0x0F)<<32 | - uint64(data[14])<<24 | - uint64(data[15])<<16 | - uint64(data[16])<<8 | - uint64(data[17]) + res := &AnalysisResult{ + FilePath: filePath, + } - if result.SampleRate > 0 { - result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) - } + if info, err := os.Stat(filePath); err == nil { + res.FileSize = info.Size() + } + + infoMap := make(map[string]string) + + args = []string{ + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate", + "-of", "default=noprint_wrappers=0", + filePath, + } + cmd = exec.Command(ffprobePath, args...) + setHideWindow(cmd) + output, err = cmd.CombinedOutput() + if err == nil { + lines = strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } } } - result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) - return result, nil + + if val, ok := infoMap["sample_rate"]; ok { + s, _ := strconv.Atoi(val) + res.SampleRate = uint32(s) + } + if val, ok := infoMap["channels"]; ok { + c, _ := strconv.Atoi(val) + res.Channels = uint8(c) + } + if val, ok := infoMap["duration"]; ok { + d, _ := strconv.ParseFloat(val, 64) + res.Duration = d + } + if val, ok := infoMap["bit_rate"]; ok && val != "N/A" { + br, _ := strconv.Atoi(val) + res.Bitrate = br + } + + bits := 0 + if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" { + bits, _ = strconv.Atoi(val) + } + if bits == 0 { + if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" { + bits, _ = strconv.Atoi(val) + } + } + + res.BitsPerSample = uint8(bits) + if bits > 0 { + res.BitDepth = fmt.Sprintf("%d-bit", bits) + } else { + res.BitDepth = "Unknown" + } + + return res, nil } diff --git a/backend/cover.go b/backend/cover.go index 5e31d3f..f7142c9 100644 --- a/backend/cover.go +++ b/backend/cover.go @@ -83,6 +83,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) + filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate)) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) diff --git a/backend/deezer.go b/backend/deezer.go new file mode 100644 index 0000000..13f9d13 --- /dev/null +++ b/backend/deezer.go @@ -0,0 +1,273 @@ +package backend + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type DeezerDownloader struct { + client *http.Client +} + +func NewDeezerDownloader() *DeezerDownloader { + return &DeezerDownloader{ + client: &http.Client{ + Timeout: 300 * time.Second, + }, + } +} + +type YoinkifyRequest struct { + URL string `json:"url"` + Format string `json:"format"` + GenreSource string `json:"genreSource"` +} + +func (d *DeezerDownloader) DownloadFromYoinkify(spotifyURL, outputDir string) (string, error) { + apiURL := "https://yoinkify.lol/api/download" + + payload := YoinkifyRequest{ + URL: spotifyURL, + Format: "flac", + GenreSource: "spotify", + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + + fmt.Printf("Fetching from Deezer API (Yoinkify)...\n") + resp, err := d.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode) + } + + tempFileName := fmt.Sprintf("deezer_%d.flac", time.Now().UnixNano()) + filePath := filepath.Join(outputDir, tempFileName) + + out, err := os.Create(filePath) + if err != nil { + return "", err + } + defer out.Close() + + fmt.Printf("Downloading track from Deezer...\n") + pw := NewProgressWriter(out) + _, err = io.Copy(pw, resp.Body) + if err != nil { + out.Close() + os.Remove(filePath) + return "", err + } + + fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) + return filePath, nil +} + +func (d *DeezerDownloader) Download(spotifyID, outputDir, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { + + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + } + + if spotifyTrackName != "" && spotifyArtistName != "" { + filenameArtist := spotifyArtistName + filenameAlbumArtist := spotifyAlbumArtist + if useFirstArtistOnly { + filenameArtist = GetFirstArtist(spotifyArtistName) + filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist) + } + expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false) + 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 + } + } + + type mbResult struct { + ISRC string + Metadata Metadata + } + + metaChan := make(chan mbResult, 1) + if (embedGenre || true) && spotifyURL != "" { + go func() { + res := mbResult{} + var isrc string + parts := strings.Split(spotifyURL, "/") + if len(parts) > 0 { + sID := strings.Split(parts[len(parts)-1], "?")[0] + if sID != "" { + client := NewSongLinkClient() + if val, err := client.GetISRC(sID); err == nil { + isrc = val + } + } + } + res.ISRC = isrc + if isrc != "" && embedGenre { + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } + } + metaChan <- res + }() + } else { + close(metaChan) + } + + filePath, err := d.DownloadFromYoinkify(spotifyURL, outputDir) + if err != nil { + return "", err + } + + var isrc string + var mbMeta Metadata + if spotifyURL != "" { + result := <-metaChan + isrc = result.ISRC + mbMeta = result.Metadata + } + + if spotifyTrackName != "" && spotifyArtistName != "" { + safeArtist := sanitizeFilename(spotifyArtistName) + safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) + + if useFirstArtistOnly { + safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName)) + safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + } + + safeTitle := sanitizeFilename(spotifyTrackName) + safeAlbum := sanitizeFilename(spotifyAlbumName) + + year := "" + if len(spotifyReleaseDate) >= 4 { + year = spotifyReleaseDate[:4] + } + + var newFilename string + + if strings.Contains(filenameFormat, "{") { + newFilename = filenameFormat + newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle) + newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist) + newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum) + newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist) + newFilename = strings.ReplaceAll(newFilename, "{year}", year) + newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate)) + + if spotifyDiscNumber > 0 { + newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber)) + } else { + newFilename = strings.ReplaceAll(newFilename, "{disc}", "") + } + + if position > 0 { + newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position)) + } else { + newFilename = strings.ReplaceAll(newFilename, "{track}", "") + } + } else { + switch filenameFormat { + case "artist-title": + newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) + case "title": + newFilename = safeTitle + default: + newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) + } + + if includeTrackNumber && position > 0 { + newFilename = fmt.Sprintf("%02d. %s", position, newFilename) + } + } + + ext := ".flac" + newFilename = newFilename + ext + newFilePath := filepath.Join(outputDir, newFilename) + + if err := os.Rename(filePath, newFilePath); err != nil { + fmt.Printf("Warning: Failed to rename file: %v\n", err) + } else { + filePath = newFilePath + fmt.Printf("Renamed to: %s\n", newFilename) + } + } + + fmt.Println("Embedding Spotify metadata...") + + coverPath := "" + if spotifyCoverURL != "" { + coverPath = filePath + ".cover.jpg" + coverClient := NewCoverClient() + if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { + fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) + coverPath = "" + } else { + defer os.Remove(coverPath) + fmt.Println("Spotify cover downloaded") + } + } + + trackNumberToEmbed := spotifyTrackNumber + if trackNumberToEmbed == 0 { + trackNumberToEmbed = 1 + } + + metadata := Metadata{ + Title: spotifyTrackName, + Artist: spotifyArtistName, + Album: spotifyAlbumName, + AlbumArtist: spotifyAlbumArtist, + Date: spotifyReleaseDate, + TrackNumber: trackNumberToEmbed, + TotalTracks: spotifyTotalTracks, + DiscNumber: spotifyDiscNumber, + TotalDiscs: spotifyTotalDiscs, + URL: spotifyURL, + Copyright: spotifyCopyright, + Publisher: spotifyPublisher, + Description: "https://github.com/afkarxyz/SpotiFLAC", + ISRC: isrc, + Genre: mbMeta.Genre, + } + + if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil { + fmt.Printf("Warning: Failed to embed metadata: %v\n", err) + } else { + fmt.Println("Metadata embedded successfully") + } + + fmt.Println("Done") + fmt.Println("✓ Downloaded successfully from Deezer") + return filePath, nil +} diff --git a/backend/filemanager.go b/backend/filemanager.go index f656554..dda8dc3 100644 --- a/backend/filemanager.go +++ b/backend/filemanager.go @@ -332,6 +332,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album)) result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year)) + result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year)) if metadata.TrackNumber > 0 { result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber)) diff --git a/backend/filename.go b/backend/filename.go index 6dd49dc..5d2be9b 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -33,6 +33,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) + filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist) filename = strings.ReplaceAll(filename, "{creator}", safeCreator) diff --git a/backend/lyrics.go b/backend/lyrics.go index 264694f..c007bd5 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -381,6 +381,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) + filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate)) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) diff --git a/backend/musicbrainz.go b/backend/musicbrainz.go index 2681c4e..63e77b0 100644 --- a/backend/musicbrainz.go +++ b/backend/musicbrainz.go @@ -54,9 +54,13 @@ type MusicBrainzRecordingResponse struct { } `json:"recordings"` } -func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool) (Metadata, error) { +func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) { var meta Metadata + if !embedGenre { + return meta, nil + } + if isrc == "" { return meta, fmt.Errorf("no ISRC provided") } diff --git a/backend/qobuz.go b/backend/qobuz.go index 7aaed22..ad438fb 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -167,7 +167,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal standardAPIs := []string{ "https://dab.yeet.su/api/stream?trackId=", "https://dabmusic.xyz/api/stream?trackId=", - "https://qobuz.squid.wtf/api/download-music?track_id=", } downloadFunc := func(qual string) (string, error) { @@ -319,6 +318,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t filename = strings.ReplaceAll(filename, "{album}", album) filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{year}", year) + filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) @@ -353,7 +353,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t return filename + ".flac" } -func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { +func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { var deezerISRC string if spotifyID != "" { songlinkClient := NewSongLinkClient() @@ -366,17 +366,17 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF return "", fmt.Errorf("spotify ID is required for Qobuz download") } - return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre) + return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } -func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { +func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC) metaChan := make(chan Metadata, 1) - if deezerISRC != "" { + if embedGenre && deezerISRC != "" { go func() { fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre); err == nil { + if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { fmt.Println("✓ MusicBrainz metadata fetched") metaChan <- fetchedMeta } else { diff --git a/backend/songlink.go b/backend/songlink.go index dced539..f8eef51 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -28,9 +28,11 @@ type TrackAvailability struct { Tidal bool `json:"tidal"` Amazon bool `json:"amazon"` Qobuz bool `json:"qobuz"` + Deezer bool `json:"deezer"` TidalURL string `json:"tidal_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"` + DeezerURL string `json:"deezer_url,omitempty"` } func NewSongLinkClient() *SongLinkClient { @@ -279,6 +281,8 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { deezerURL := deezerLink.URL + availability.Deezer = true + availability.DeezerURL = deezerURL deezerISRC, err := getDeezerISRC(deezerURL) if err == nil && deezerISRC != "" { diff --git a/backend/tidal.go b/backend/tidal.go index f19e6c8..ecbe095 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -79,13 +79,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { apis := []string{ - "https://api.monochrome.tf", - "https://arran.monochrome.tf", "https://triton.squid.wtf", "https://hifi-one.spotisaver.net", "https://hifi-two.spotisaver.net", - "https://tidal.kinoplus.online", - "https://tidal-api.binimum.org", } return apis, nil } @@ -448,7 +444,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } -func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -508,7 +504,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } metaChan := make(chan mbResult, 1) - if spotifyURL != "" { + if embedGenre && spotifyURL != "" { go func() { res := mbResult{} var isrc string @@ -525,7 +521,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo res.ISRC = isrc if isrc != "" { fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre); err == nil { + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { res.Metadata = fetchedMeta fmt.Println("✓ MusicBrainz metadata fetched") } else { @@ -601,7 +597,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return outputFilename, nil } -func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -666,7 +662,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } metaChan := make(chan mbResultFallback, 1) - if spotifyURL != "" { + if embedGenre && spotifyURL != "" { go func() { res := mbResultFallback{} var isrc string @@ -683,7 +679,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality res.ISRC = isrc if isrc != "" { fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre); err == nil { + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { res.Metadata = fetchedMeta fmt.Println("✓ MusicBrainz metadata fetched") } else { @@ -760,14 +756,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return outputFilename, nil } -func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) } - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } type SegmentTemplate struct { @@ -1023,6 +1019,7 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t filename = strings.ReplaceAll(filename, "{album}", album) filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{year}", year) + filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index aa98c54..f2aeda0 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -317,7 +317,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort

{artistInfo.name}

{artistInfo.verified && ()}
- {artistInfo.biography && (

{artistInfo.biography}

)} + {artistInfo.biography && (

{artistInfo.biography}

)}
{artistInfo.rank && (<> #{artistInfo.rank} rank @@ -370,7 +370,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort

{artistInfo.name}

{artistInfo.verified && ()}
- {artistInfo.biography && (

{artistInfo.biography}

)} + {artistInfo.biography && (

{artistInfo.biography}

)}
{artistInfo.rank && (<> #{artistInfo.rank} rank diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx index 3b8e31a..8c2a4f2 100644 --- a/frontend/src/components/AudioConverterPage.tsx +++ b/frontend/src/components/AudioConverterPage.tsx @@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label"; import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group"; import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; -import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App"; +import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; interface AudioFile { @@ -152,6 +152,27 @@ export function AudioConverterPage() { }); } }; + const handleSelectFolder = async () => { + try { + const selectedFolder = await SelectFolder(""); + if (selectedFolder) { + const folderFiles = await ListAudioFilesInDir(selectedFolder); + if (folderFiles && folderFiles.length > 0) { + addFiles(folderFiles.map((f) => f.path)); + } + else { + toast.info("No audio files found", { + description: "No FLAC or MP3 files found in the selected folder.", + }); + } + } + } + catch (err) { + toast.error("Folder Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select folder", + }); + } + }; const addFiles = useCallback(async (paths: string[]) => { const validExtensions = [".mp3", ".flac"]; const m4aFiles = paths.filter((path) => { @@ -298,7 +319,11 @@ export function AudioConverterPage() { {files.length > 0 && (
+ +
+ + +

Supported formats: FLAC, MP3

diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 6378151..e2d678f 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -35,7 +35,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {

- Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required. + Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.

); diff --git a/frontend/src/components/HistoryPage.tsx b/frontend/src/components/HistoryPage.tsx index 1c4274c..9f34b4c 100644 --- a/frontend/src/components/HistoryPage.tsx +++ b/frontend/src/components/HistoryPage.tsx @@ -303,7 +303,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
- {['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format} + {['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()} {item.quality && {item.quality}}
diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index c508105..f07e3c1 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -16,3 +16,8 @@ export const AmazonIcon = ({ className = "w-4 h-4" }: { ); +export const DeezerIcon = ({ className = "w-4 h-4" }: { + className?: string; +}) => ( + + ); diff --git a/frontend/src/components/SearchAndSort.tsx b/frontend/src/components/SearchAndSort.tsx index a374b37..29a0559 100644 --- a/frontend/src/components/SearchAndSort.tsx +++ b/frontend/src/components/SearchAndSort.tsx @@ -33,6 +33,7 @@ export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChang Plays (High) Downloaded Not Downloaded + Failed Downloads ); diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index cd2fbd0..52677c3 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -30,6 +30,11 @@ const AmazonIcon = ({ className }: { ); +const DeezerIcon = ({ className }: { + className?: string; +}) => ( + + ); interface SettingsPageProps { onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onResetRequest?: (resetFn: () => void) => void; @@ -118,7 +123,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { setTempSettings((prev) => ({ ...prev, tidalQuality: value })); }; - const handleQobuzQualityChange = (value: "6" | "7") => { + const handleQobuzQualityChange = (value: "6" | "7" | "27") => { setTempSettings((prev) => ({ ...prev, qobuzQuality: value })); }; const handleAutoQualityChange = async (value: "16" | "24") => { @@ -234,7 +239,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
- setTempSettings((prev) => ({ ...prev, downloader: value, }))}> @@ -261,6 +266,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin Amazon Music + + + + Deezer + + @@ -273,6 +284,193 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -313,62 +511,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -403,19 +545,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin 16-bit/44.1kHz - 24-bit/48kHz + 24-bit/48kHz - 192kHz )} {tempSettings.downloader === "amazon" && (
16-bit - 24-bit/44.1kHz - 192kHz
)} + {tempSettings.downloader === "deezer" && (
+ 16-bit/44.1kHz +
)}
{((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") || (tempSettings.downloader === "qobuz" && - tempSettings.qobuzQuality === "7") || + tempSettings.qobuzQuality === "27") || (tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (
@@ -452,14 +597,23 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
- setTempSettings((prev) => ({ + setTempSettings((prev) => ({ ...prev, - useSingleGenre: checked, + embedGenre: checked, }))}/> -
+ {tempSettings.embedGenre && (
+ setTempSettings((prev) => ({ + ...prev, + useSingleGenre: checked, + }))}/> + +
)}
)} @@ -513,7 +667,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin .replace(/\{artist\}/g, "Kendrick Lamar, SZA") .replace(/\{album\}/g, "Black Panther") .replace(/\{album_artist\}/g, "Kendrick Lamar") - .replace(/\{year\}/g, "2018")} + .replace(/\{year\}/g, "2018") + .replace(/\{date\}/g, "2018-02-09")} /

)} @@ -601,7 +756,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin .replace(/\{title\}/g, "All The Stars") .replace(/\{track\}/g, "01") .replace(/\{disc\}/g, "1") - .replace(/\{year\}/g, "2018")} + .replace(/\{year\}/g, "2018") + .replace(/\{date\}/g, "2018-02-09")} .flac

)} diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index 6dce36a..9d3b437 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; interface TrackInfoProps { track: TrackMetadata & { @@ -143,6 +143,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded + ) : (

Check Availability

)} )} diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 3ca3db5..3ba907b 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; interface TrackListProps { tracks: TrackMetadata[]; @@ -116,6 +116,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); }); } + else if (sortBy === "failed") { + filteredTracks = [...filteredTracks].sort((a, b) => { + const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false; + const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false; + return (bFailed ? 1 : 0) - (aFailed ? 1 : 0); + }); + } const totalPages = Math.ceil(filteredTracks.length / itemsPerPage); const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; @@ -324,6 +331,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa + ) : (

Check Availability

)} )} diff --git a/frontend/src/hooks/useCover.ts b/frontend/src/hooks/useCover.ts index cf78b42..89c154a 100644 --- a/frontend/src/hooks/useCover.ts +++ b/frontend/src/hooks/useCover.ts @@ -2,7 +2,7 @@ import { useState, useRef } from "react"; import { downloadCover } from "@/lib/api"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { joinPath, sanitizePath } from "@/lib/utils"; +import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils"; import { logger } from "@/lib/logger"; import type { TrackMetadata } from "@/types/api"; export function useCover() { @@ -29,12 +29,16 @@ export function useCover() { let outputDir = settings.downloadPath; const placeholder = "__SLASH_PLACEHOLDER__"; const yearValue = releaseDate?.substring(0, 4); + const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName; + const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; const templateData: TemplateData = { - artist: artistName?.replace(/\//g, placeholder), + artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), + album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), track: position, year: yearValue, + date: releaseDate, playlist: playlistName?.replace(/\//g, placeholder), }; const folderTemplate = settings.folderTemplate || ""; @@ -55,9 +59,9 @@ export function useCover() { const response = await downloadCover({ cover_url: coverUrl, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName || "", - album_artist: albumArtist || "", + album_artist: displayAlbumArtist || "", release_date: releaseDate || "", output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", @@ -127,12 +131,16 @@ export function useCover() { const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); const yearValue = track.release_date?.substring(0, 4); + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist; const templateData: TemplateData = { - artist: track.artists?.replace(/\//g, placeholder), + artist: displayArtist?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder), + album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder), track: trackPosition, year: yearValue, + date: track.release_date, playlist: playlistName?.replace(/\//g, placeholder), }; const folderTemplate = settings.folderTemplate || ""; @@ -153,9 +161,9 @@ export function useCover() { const response = await downloadCover({ cover_url: track.images, track_name: track.name, - artist_name: track.artists, + artist_name: displayArtist, album_name: track.album_name, - album_artist: track.album_artist, + album_artist: displayAlbumArtist, release_date: track.release_date, output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index f881b38..366af2b 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -2,16 +2,9 @@ import { useState, useRef } from "react"; import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { joinPath, sanitizePath } from "@/lib/utils"; +import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils"; import { logger } from "@/lib/logger"; import type { TrackMetadata } from "@/types/api"; -function getFirstArtist(artistString: string): string { - if (!artistString) - return artistString; - const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i; - const parts = artistString.split(delimiters); - return parts[0].trim(); -} interface CheckFileExistenceRequest { spotify_id: string; track_name: string; @@ -95,6 +88,7 @@ export function useDownload(region: string) { title: trackName?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, + date: releaseDate, playlist: playlistName?.replace(/\//g, placeholder), }; const folderTemplate = settings.folderTemplate || ""; @@ -166,9 +160,10 @@ export function useDownload(region: string) { const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); let lastResponse: any = { success: false, error: "No matching services found" }; + const fallbackErrors: string[] = []; const is24Bit = (settings.autoQuality || "24") === "24"; const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; - const qobuzQuality = is24Bit ? "7" : "6"; + const qobuzQuality = is24Bit ? "27" : "6"; for (const s of order) { if (s === "tidal" && streamingURLs?.tidal_url) { try { @@ -202,16 +197,20 @@ export function useDownload(region: string) { publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (response.success) { logger.success(`tidal: ${trackName} - ${artistName}`); return response; } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Tidal] ${errMsg}`); lastResponse = response; logger.warning(`tidal failed, trying next...`); } catch (err) { logger.error(`tidal error: ${err}`); + fallbackErrors.push(`[Tidal] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } @@ -244,16 +243,20 @@ export function useDownload(region: string) { copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (response.success) { logger.success(`amazon: ${trackName} - ${artistName}`); return response; } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Amazon] ${errMsg}`); lastResponse = response; logger.warning(`amazon failed, trying next...`); } catch (err) { logger.error(`amazon error: ${err}`); + fallbackErrors.push(`[Amazon] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } @@ -286,23 +289,76 @@ export function useDownload(region: string) { copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (response.success) { logger.success(`qobuz: ${trackName} - ${artistName}`); return response; } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Qobuz] ${errMsg}`); lastResponse = response; logger.warning(`qobuz failed, trying next...`); } catch (err) { logger.error(`qobuz error: ${err}`); + fallbackErrors.push(`[Qobuz] ${String(err)}`); + lastResponse = { success: false, error: String(err) }; + } + } + else if (s === "deezer") { + try { + logger.debug(`trying deezer for: ${trackName} - ${artistName}`); + const response = await downloadTrack({ + service: "deezer", + query, + track_name: trackName, + artist_name: displayArtist, + album_name: albumName, + album_artist: displayAlbumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position, + use_album_track_number: useAlbumTrackNumber, + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + embed_max_quality_cover: settings.embedMaxQualityCover, + duration: durationSeconds, + item_id: itemID, + audio_format: "flac", + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, + use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, + }); + if (response.success) { + logger.success(`deezer: ${trackName} - ${artistName}`); + return response; + } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Deezer] ${errMsg}`); + lastResponse = response; + logger.warning(`deezer failed, trying next...`); + } + catch (err) { + logger.error(`deezer error: ${err}`); + fallbackErrors.push(`[Deezer] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } } if (itemID) { const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed"); + const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed"); + await MarkDownloadItemFailed(itemID, finalError); } return lastResponse; } @@ -314,8 +370,12 @@ export function useDownload(region: string) { else if (service === "qobuz") { audioFormat = settings.qobuzQuality || "6"; } + else if (service === "deezer") { + audioFormat = "flac"; + } + logger.debug(`trying ${service} for: ${trackName} - ${artistName}`); const singleServiceResponse = await downloadTrack({ - service: service as "tidal" | "qobuz" | "amazon", + service: service as "tidal" | "qobuz" | "amazon" | "deezer", query, track_name: trackName, artist_name: displayArtist, @@ -341,6 +401,7 @@ export function useDownload(region: string) { copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (!singleServiceResponse.success && itemID) { const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); @@ -389,6 +450,7 @@ export function useDownload(region: string) { title: trackName?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, + date: releaseDate, playlist: folderName?.replace(/\//g, placeholder), }; const folderTemplate = settings.folderTemplate || ""; @@ -421,12 +483,14 @@ export function useDownload(region: string) { const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); let lastResponse: any = { success: false, error: "No matching services found" }; + const fallbackErrors: string[] = []; const is24Bit = (settings.autoQuality || "24") === "24"; const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; - const qobuzQuality = is24Bit ? "7" : "6"; + const qobuzQuality = is24Bit ? "27" : "6"; for (const s of order) { if (s === "tidal" && streamingURLs?.tidal_url) { try { + logger.debug(`trying tidal for: ${trackName} - ${artistName}`); const response = await downloadTrack({ service: "tidal", query, @@ -456,19 +520,26 @@ export function useDownload(region: string) { publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (response.success) { + logger.success(`tidal: ${trackName} - ${artistName}`); return response; } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Tidal] ${errMsg}`); lastResponse = response; + logger.warning(`tidal failed, trying next...`); } catch (err) { - console.error("Tidal error:", err); + logger.error(`tidal error: ${err}`); + fallbackErrors.push(`[Tidal] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } else if (s === "amazon" && streamingURLs?.amazon_url) { try { + logger.debug(`trying amazon for: ${trackName} - ${artistName}`); const response = await downloadTrack({ service: "amazon", query, @@ -496,19 +567,26 @@ export function useDownload(region: string) { publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (response.success) { + logger.success(`amazon: ${trackName} - ${artistName}`); return response; } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Amazon] ${errMsg}`); lastResponse = response; + logger.warning(`amazon failed, trying next...`); } catch (err) { - console.error("Amazon error:", err); + logger.error(`amazon error: ${err}`); + fallbackErrors.push(`[Amazon] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } else if (s === "qobuz") { try { + logger.debug(`trying qobuz for: ${trackName} - ${artistName}`); const response = await downloadTrack({ service: "qobuz", query, @@ -537,21 +615,76 @@ export function useDownload(region: string) { publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (response.success) { + logger.success(`qobuz: ${trackName} - ${artistName}`); return response; } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Qobuz] ${errMsg}`); lastResponse = response; + logger.warning(`qobuz failed, trying next...`); } catch (err) { - console.error("Qobuz error:", err); + logger.error(`qobuz error: ${err}`); + fallbackErrors.push(`[Qobuz] ${String(err)}`); + lastResponse = { success: false, error: String(err) }; + } + } + else if (s === "deezer") { + try { + logger.debug(`trying deezer for: ${trackName} - ${artistName}`); + const response = await downloadTrack({ + service: "deezer", + query, + track_name: trackName, + artist_name: displayArtist, + album_name: albumName, + album_artist: displayAlbumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position: trackNumberForTemplate, + use_album_track_number: useAlbumTrackNumber, + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + embed_max_quality_cover: settings.embedMaxQualityCover, + duration: durationSeconds, + item_id: itemID, + audio_format: "flac", + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, + use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, + }); + if (response.success) { + logger.success(`deezer: ${trackName} - ${artistName}`); + return response; + } + const errMsg = response.error || response.message || "Failed"; + fallbackErrors.push(`[Deezer] ${errMsg}`); + lastResponse = response; + logger.warning(`deezer failed, trying next...`); + } + catch (err) { + logger.error(`deezer error: ${err}`); + fallbackErrors.push(`[Deezer] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } } if (!lastResponse.success && itemID) { const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed"); + const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed"); + await MarkDownloadItemFailed(itemID, finalError); } return lastResponse; } @@ -563,8 +696,11 @@ export function useDownload(region: string) { else if (service === "qobuz") { audioFormat = settings.qobuzQuality || "6"; } + else if (service === "deezer") { + audioFormat = "flac"; + } const singleServiceResponse = await downloadTrack({ - service: service as "tidal" | "qobuz" | "amazon", + service: service as "tidal" | "qobuz" | "amazon" | "deezer", query, track_name: trackName, artist_name: displayArtist, @@ -589,6 +725,9 @@ export function useDownload(region: string) { spotify_total_discs: spotifyTotalDiscs, copyright: copyright, publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, + use_single_genre: settings.useSingleGenre, + embed_genre: settings.embedGenre, }); if (!singleServiceResponse.success && itemID) { const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts index 57f2d28..ec24b89 100644 --- a/frontend/src/hooks/useLyrics.ts +++ b/frontend/src/hooks/useLyrics.ts @@ -2,7 +2,7 @@ import { useState, useRef } from "react"; import { downloadLyrics } from "@/lib/api"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { joinPath, sanitizePath } from "@/lib/utils"; +import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils"; import { logger } from "@/lib/logger"; import type { TrackMetadata } from "@/types/api"; export function useLyrics() { @@ -26,12 +26,16 @@ export function useLyrics() { let outputDir = settings.downloadPath; const placeholder = "__SLASH_PLACEHOLDER__"; const yearValue = releaseDate?.substring(0, 4); + const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName; + const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; const templateData: TemplateData = { - artist: artistName?.replace(/\//g, placeholder), + artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), + album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), track: position, year: yearValue, + date: releaseDate, playlist: playlistName?.replace(/\//g, placeholder), }; const folderTemplate = settings.folderTemplate || ""; @@ -53,9 +57,9 @@ export function useLyrics() { const response = await downloadLyrics({ spotify_id: spotifyId, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: releaseDate, output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", @@ -123,12 +127,16 @@ export function useLyrics() { const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); const yearValue = track.release_date?.substring(0, 4); + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist; const templateData: TemplateData = { - artist: track.artists?.replace(/\//g, placeholder), + artist: displayArtist?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder), + album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder), track: trackPosition, year: yearValue, + date: track.release_date, playlist: playlistName?.replace(/\//g, placeholder), }; const folderTemplate = settings.folderTemplate || ""; @@ -149,9 +157,9 @@ export function useLyrics() { const response = await downloadLyrics({ spotify_id: id, track_name: track.name, - artist_name: track.artists, + artist_name: displayArtist, album_name: track.album_name, - album_artist: track.album_artist, + album_artist: displayAlbumArtist, release_date: track.release_date, output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 6dc0e05..e9be2c3 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -4,7 +4,7 @@ export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-ar export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom"; export interface Settings { downloadPath: string; - downloader: "auto" | "tidal" | "qobuz" | "amazon"; + downloader: "auto" | "tidal" | "qobuz" | "amazon" | "deezer"; theme: string; themeMode: "auto" | "light" | "dark"; fontFamily: FontFamily; @@ -21,9 +21,9 @@ export interface Settings { embedMaxQualityCover: boolean; operatingSystem: "Windows" | "linux/MacOS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; - qobuzQuality: "6" | "7"; + qobuzQuality: "6" | "7" | "27"; amazonQuality: "original"; - autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz"; + autoOrder: "tidal-qobuz-amazon-deezer" | "tidal-qobuz-deezer-amazon" | "tidal-amazon-qobuz-deezer" | "tidal-amazon-deezer-qobuz" | "tidal-deezer-qobuz-amazon" | "tidal-deezer-amazon-qobuz" | "qobuz-tidal-amazon-deezer" | "qobuz-tidal-deezer-amazon" | "qobuz-amazon-tidal-deezer" | "qobuz-amazon-deezer-tidal" | "qobuz-deezer-tidal-amazon" | "qobuz-deezer-amazon-tidal" | "amazon-tidal-qobuz-deezer" | "amazon-tidal-deezer-qobuz" | "amazon-qobuz-tidal-deezer" | "amazon-qobuz-deezer-tidal" | "amazon-deezer-tidal-qobuz" | "amazon-deezer-qobuz-tidal" | "deezer-tidal-qobuz-amazon" | "deezer-tidal-amazon-qobuz" | "deezer-qobuz-tidal-amazon" | "deezer-qobuz-amazon-tidal" | "deezer-amazon-tidal-qobuz" | "deezer-amazon-qobuz-tidal" | string; autoQuality: "16" | "24"; allowFallback: boolean; useSpotFetchAPI: boolean; @@ -32,6 +32,7 @@ export interface Settings { createM3u8File: boolean; useFirstArtistOnly: boolean; useSingleGenre: boolean; + embedGenre: boolean; } export const FOLDER_PRESETS: Record { if (!('qobuzQuality' in parsed)) { parsed.qobuzQuality = "6"; } - if (parsed.qobuzQuality === "27") { - parsed.qobuzQuality = "6"; - } if (!('amazonQuality' in parsed)) { parsed.amazonQuality = "original"; } @@ -314,6 +311,9 @@ export async function loadSettings(): Promise { if (!('useSingleGenre' in parsed)) { parsed.useSingleGenre = false; } + if (!('embedGenre' in parsed)) { + parsed.embedGenre = true; + } cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; return cachedSettings!; } @@ -339,6 +339,7 @@ export interface TemplateData { track?: number; disc?: number; year?: string; + date?: string; playlist?: string; } export function parseTemplate(template: string, data: TemplateData): string { @@ -352,6 +353,7 @@ export function parseTemplate(template: string, data: TemplateData): string { result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00"); result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1"); result = result.replace(/\{year\}/g, data.year || "0000"); + result = result.replace(/\{date\}/g, data.date || "0000-00-00"); result = result.replace(/\{playlist\}/g, data.playlist || ""); return result; } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 5537671..281e526 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -46,3 +46,10 @@ export function openExternal(url: string) { } } } +export function getFirstArtist(artistString: string): string { + if (!artistString) + return artistString; + const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i; + const parts = artistString.split(delimiters); + return parts[0].trim(); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 79edc78..a3d79c3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -108,7 +108,7 @@ export interface ArtistResponse { } export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse; export interface DownloadRequest { - service: "tidal" | "qobuz" | "amazon"; + service: "tidal" | "qobuz" | "amazon" | "deezer"; query?: string; track_name?: string; artist_name?: string; @@ -139,6 +139,7 @@ export interface DownloadRequest { spotify_url?: string; use_first_artist_only?: boolean; use_single_genre?: boolean; + embed_genre?: boolean; } export interface DownloadResponse { success: boolean; @@ -203,9 +204,11 @@ export interface TrackAvailability { tidal: boolean; amazon: boolean; qobuz: boolean; + deezer: boolean; tidal_url?: string; amazon_url?: string; qobuz_url?: string; + deezer_url?: string; } export interface CoverDownloadRequest { cover_url: string;