This commit is contained in:
afkarxyz
2026-02-25 14:20:48 +07:00
parent 9ef24f5a91
commit 3d8ff2cedd
28 changed files with 916 additions and 182 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
<div align="center"> <div align="center">
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=) ![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) ![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 ## 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] > [!TIP]
> >
+20 -9
View File
@@ -90,6 +90,7 @@ type DownloadRequest struct {
AllowFallback bool `json:"allow_fallback"` AllowFallback bool `json:"allow_fallback"`
UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"` UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"`
UseSingleGenre bool `json:"use_single_genre,omitempty"` UseSingleGenre bool `json:"use_single_genre,omitempty"`
EmbedGenre bool `json:"embed_genre,omitempty"`
} }
type DownloadResponse struct { type DownloadResponse struct {
@@ -368,25 +369,25 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
downloader := backend.NewAmazonDownloader() downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" { 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 { } 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": case "tidal":
if req.ApiURL == "" || req.ApiURL == "auto" { if req.ApiURL == "" || req.ApiURL == "auto" {
downloader := backend.NewTidalDownloader("") downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" { 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 { } 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 { } else {
downloader := backend.NewTidalDownloader(req.ApiURL) downloader := backend.NewTidalDownloader(req.ApiURL)
if req.ServiceURL != "" { 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 { } 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 == "" { if quality == "" {
quality = "6" 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: default:
return DownloadResponse{ return DownloadResponse{
@@ -480,11 +485,17 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
meta, err := backend.GetTrackMetadata(fPath) meta, err := backend.GetTrackMetadata(fPath)
if err == nil && meta != nil { if err == nil && meta != nil {
if meta.BitsPerSample > 0 {
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.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) d := int(meta.Duration)
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60) durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
} else { } else {
fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err)
} }
item := backend.HistoryItem{ item := backend.HistoryItem{
@@ -495,7 +506,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
DurationStr: durationStr, DurationStr: durationStr,
CoverURL: cover, CoverURL: cover,
Quality: quality, Quality: quality,
Format: format, Format: strings.ToUpper(format),
Path: fPath, Path: fPath,
} }
+7 -6
View File
@@ -111,7 +111,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) 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) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -259,7 +259,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) 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 outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { 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) metaChan := make(chan mbResult, 1)
if spotifyURL != "" { if embedGenre && spotifyURL != "" {
go func() { go func() {
res := mbResult{} res := mbResult{}
var isrc string var isrc string
@@ -306,7 +306,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
res.ISRC = isrc res.ISRC = isrc
if isrc != "" { if isrc != "" {
fmt.Println("Fetching MusicBrainz metadata...") 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 res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched") fmt.Println("✓ MusicBrainz metadata fetched")
} else { } else {
@@ -363,6 +363,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum) newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist) newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year) newFilename = strings.ReplaceAll(newFilename, "{year}", year)
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
if spotifyDiscNumber > 0 { if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber)) 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, 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) { ) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
@@ -480,5 +481,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit
return "", err 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)
} }
+94 -23
View File
@@ -4,6 +4,10 @@ import (
"fmt" "fmt"
"math" "math"
"os" "os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac"
mewflac "github.com/mewkiz/flac" mewflac "github.com/mewkiz/flac"
@@ -17,6 +21,7 @@ type AnalysisResult struct {
BitsPerSample uint8 `json:"bits_per_sample"` BitsPerSample uint8 `json:"bits_per_sample"`
TotalSamples uint64 `json:"total_samples"` TotalSamples uint64 `json:"total_samples"`
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
Bitrate int `json:"bit_rate"`
BitDepth string `json:"bit_depth"` BitDepth string `json:"bit_depth"`
DynamicRange float64 `json:"dynamic_range"` DynamicRange float64 `json:"dynamic_range"`
PeakAmplitude float64 `json:"peak_amplitude"` 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) 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 { 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 { 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{ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
FilePath: filepath, if len(lines) < 4 {
FileSize: fileInfo.Size(), return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
} }
if len(f.Meta) > 0 { res := &AnalysisResult{
streamInfo := f.Meta[0] FilePath: filePath,
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])
if result.SampleRate > 0 { if info, err := os.Stat(filePath); err == nil {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) 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])
} }
} }
} }
if val, ok := infoMap["sample_rate"]; ok {
s, _ := strconv.Atoi(val)
res.SampleRate = uint32(s)
} }
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) if val, ok := infoMap["channels"]; ok {
return result, nil 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
} }
+1
View File
@@ -83,6 +83,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+273
View File
@@ -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
}
+1
View File
@@ -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}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year)) result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber)) result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
+1
View File
@@ -33,6 +33,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist) filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
filename = strings.ReplaceAll(filename, "{creator}", safeCreator) filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
+1
View File
@@ -381,6 +381,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
filename = strings.ReplaceAll(filename, "{album}", safeAlbum) filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+5 -1
View File
@@ -54,9 +54,13 @@ type MusicBrainzRecordingResponse struct {
} `json:"recordings"` } `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 var meta Metadata
if !embedGenre {
return meta, nil
}
if isrc == "" { if isrc == "" {
return meta, fmt.Errorf("no ISRC provided") return meta, fmt.Errorf("no ISRC provided")
} }
+6 -6
View File
@@ -167,7 +167,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
standardAPIs := []string{ standardAPIs := []string{
"https://dab.yeet.su/api/stream?trackId=", "https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=", "https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.squid.wtf/api/download-music?track_id=",
} }
downloadFunc := func(qual string) (string, error) { 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}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) 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" 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 var deezerISRC string
if spotifyID != "" { if spotifyID != "" {
songlinkClient := NewSongLinkClient() 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 "", 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) fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
metaChan := make(chan Metadata, 1) metaChan := make(chan Metadata, 1)
if deezerISRC != "" { if embedGenre && deezerISRC != "" {
go func() { go func() {
fmt.Println("Fetching MusicBrainz metadata...") 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") fmt.Println("✓ MusicBrainz metadata fetched")
metaChan <- fetchedMeta metaChan <- fetchedMeta
} else { } else {
+4
View File
@@ -28,9 +28,11 @@ type TrackAvailability struct {
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"` Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"` Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"` TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
} }
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
@@ -279,6 +281,8 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL deezerURL := deezerLink.URL
availability.Deezer = true
availability.DeezerURL = deezerURL
deezerISRC, err := getDeezerISRC(deezerURL) deezerISRC, err := getDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" { if err == nil && deezerISRC != "" {
+9 -12
View File
@@ -79,13 +79,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis := []string{ apis := []string{
"https://api.monochrome.tf",
"https://arran.monochrome.tf",
"https://triton.squid.wtf", "https://triton.squid.wtf",
"https://hifi-one.spotisaver.net", "https://hifi-one.spotisaver.net",
"https://hifi-two.spotisaver.net", "https://hifi-two.spotisaver.net",
"https://tidal.kinoplus.online",
"https://tidal-api.binimum.org",
} }
return apis, nil return apis, nil
} }
@@ -448,7 +444,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
return nil 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 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)
@@ -508,7 +504,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
} }
metaChan := make(chan mbResult, 1) metaChan := make(chan mbResult, 1)
if spotifyURL != "" { if embedGenre && spotifyURL != "" {
go func() { go func() {
res := mbResult{} res := mbResult{}
var isrc string var isrc string
@@ -525,7 +521,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
res.ISRC = isrc res.ISRC = isrc
if isrc != "" { if isrc != "" {
fmt.Println("Fetching MusicBrainz metadata...") 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 res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched") fmt.Println("✓ MusicBrainz metadata fetched")
} else { } else {
@@ -601,7 +597,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return outputFilename, nil 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() 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)
@@ -666,7 +662,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
} }
metaChan := make(chan mbResultFallback, 1) metaChan := make(chan mbResultFallback, 1)
if spotifyURL != "" { if embedGenre && spotifyURL != "" {
go func() { go func() {
res := mbResultFallback{} res := mbResultFallback{}
var isrc string var isrc string
@@ -683,7 +679,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
res.ISRC = isrc res.ISRC = isrc
if isrc != "" { if isrc != "" {
fmt.Println("Fetching MusicBrainz metadata...") 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 res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched") fmt.Println("✓ MusicBrainz metadata fetched")
} else { } else {
@@ -760,14 +756,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return outputFilename, nil 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) tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil { if err != nil {
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) 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 { 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}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
if discNumber > 0 { if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+2 -2
View File
@@ -317,7 +317,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2> <h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)} {artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
</div> </div>
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)} {artistInfo.biography && (<p className="text-sm text-white/90 line-clamp-4">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90"> <div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
{artistInfo.rank && (<> {artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span> <span>#{artistInfo.rank} rank</span>
@@ -370,7 +370,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
<h2 className="text-4xl font-bold">{artistInfo.name}</h2> <h2 className="text-4xl font-bold">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)} {artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
</div> </div>
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)} {artistInfo.biography && (<p className="text-sm text-muted-foreground line-clamp-4">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap"> <div className="flex items-center gap-2 text-sm flex-wrap">
{artistInfo.rank && (<> {artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span> <span>#{artistInfo.rank} rank</span>
+33 -2
View File
@@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react"; import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; 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 { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioFile { 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 addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"]; const validExtensions = [".mp3", ".flac"];
const m4aFiles = paths.filter((path) => { const m4aFiles = paths.filter((path) => {
@@ -298,7 +319,11 @@ export function AudioConverterPage() {
{files.length > 0 && (<div className="flex gap-2"> {files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}> <Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/> <Upload className="h-4 w-4"/>
Add More Add Files
</Button>
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
<Upload className="h-4 w-4"/>
Add Folder
</Button> </Button>
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}> <Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
<Trash2 className="h-4 w-4"/> <Trash2 className="h-4 w-4"/>
@@ -329,10 +354,16 @@ export function AudioConverterPage() {
? "Drop your audio files here" ? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"} : "Drag and drop audio files here, or click the button below to select"}
</p> </p>
<div className="flex gap-3">
<Button onClick={handleSelectFiles} size="lg"> <Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/> <Upload className="h-5 w-5"/>
Select Files Select Files
</Button> </Button>
<Button onClick={handleSelectFolder} size="lg" variant="outline">
<Upload className="h-5 w-5"/>
Select Folder
</Button>
</div>
<p className="text-xs text-muted-foreground mt-4 text-center"> <p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3 Supported formats: FLAC, MP3
</p> </p>
+1 -1
View File
@@ -35,7 +35,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
</div> </div>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
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.
</p> </p>
</div> </div>
</div>); </div>);
+1 -1
View File
@@ -303,7 +303,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<td className="p-3 align-middle text-left hidden lg:table-cell"> <td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground"> <span className="text-xs font-bold text-foreground">
{['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()}
</span> </span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>} {item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div> </div>
@@ -16,3 +16,8 @@ export const AmazonIcon = ({ className = "w-4 h-4" }: {
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>); </svg>);
export const DeezerIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 512 512" className={`${className} fill-current`}>
<path d="M14.8 101.1C6.6 101.1 0 127.6 0 160.3s6.6 59.2 14.8 59.2s14.8-26.5 14.8-59.2s-6.6-59.2-14.8-59.2m433.9-60.2c-7.7 0-14.5 17.1-19.4 44.1c-7.7-46.7-20.2-77-34.2-77c-16.8 0-31.1 42.9-38 105.4c-6.6-45.4-16.8-74.2-28.3-74.2c-16.1 0-29.6 56.9-34.7 136.2c-9.4-40.8-23.2-66.3-38.3-66.3s-28.8 25.5-38.3 66.3c-5.1-79.3-18.6-136.2-34.7-136.2c-11.5 0-21.7 28.8-28.3 74.2C147.9 50.9 133.3 8 116.7 8c-14 0-26.5 30.4-34.2 77c-4.8-27-11.7-44.1-19.4-44.1c-14.3 0-26 59.2-26 132.1S49 305.2 63.3 305.2c5.9 0 11.5-9.9 15.8-26.8c6.9 61.7 21.2 104.1 38 104.1c13 0 24.5-25.5 32.1-65.6c5.4 76.3 18.6 130.4 34.2 130.4c9.7 0 18.6-21.4 25.3-56.4c7.9 72.2 26.3 122.7 47.7 122.7s39.5-50.5 47.7-122.7c6.6 35 15.6 56.4 25.3 56.4c15.6 0 28.8-54.1 34.2-130.4c7.7 40.1 19.4 65.6 32.1 65.6c16.6 0 30.9-42.3 38-104.1c4.3 16.8 9.7 26.8 15.8 26.8c14.3 0 26-59.2 26-132.1S463 40.9 448.7 40.9m48.5 60.2c-8.2 0-14.8 26.5-14.8 59.2s6.6 59.2 14.8 59.2S512 193 512 160.3s-6.6-59.2-14.8-59.2"/>
</svg>);
@@ -33,6 +33,7 @@ export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChang
<SelectItem value="plays-desc">Plays (High)</SelectItem> <SelectItem value="plays-desc">Plays (High)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem> <SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem> <SelectItem value="not-downloaded">Not Downloaded</SelectItem>
<SelectItem value="failed">Failed Downloads</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div>); </div>);
+219 -63
View File
@@ -30,6 +30,11 @@ const AmazonIcon = ({ className }: {
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>); </svg>);
const DeezerIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 512 512" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path fill="currentColor" d="M14.8 101.1C6.6 101.1 0 127.6 0 160.3s6.6 59.2 14.8 59.2s14.8-26.5 14.8-59.2s-6.6-59.2-14.8-59.2m433.9-60.2c-7.7 0-14.5 17.1-19.4 44.1c-7.7-46.7-20.2-77-34.2-77c-16.8 0-31.1 42.9-38 105.4c-6.6-45.4-16.8-74.2-28.3-74.2c-16.1 0-29.6 56.9-34.7 136.2c-9.4-40.8-23.2-66.3-38.3-66.3s-28.8 25.5-38.3 66.3c-5.1-79.3-18.6-136.2-34.7-136.2c-11.5 0-21.7 28.8-28.3 74.2C147.9 50.9 133.3 8 116.7 8c-14 0-26.5 30.4-34.2 77c-4.8-27-11.7-44.1-19.4-44.1c-14.3 0-26 59.2-26 132.1S49 305.2 63.3 305.2c5.9 0 11.5-9.9 15.8-26.8c6.9 61.7 21.2 104.1 38 104.1c13 0 24.5-25.5 32.1-65.6c5.4 76.3 18.6 130.4 34.2 130.4c9.7 0 18.6-21.4 25.3-56.4c7.9 72.2 26.3 122.7 47.7 122.7s39.5-50.5 47.7-122.7c6.6 35 15.6 56.4 25.3 56.4c15.6 0 28.8-54.1 34.2-130.4c7.7 40.1 19.4 65.6 32.1 65.6c16.6 0 30.9-42.3 38-104.1c4.3 16.8 9.7 26.8 15.8 26.8c14.3 0 26-59.2 26-132.1S463 40.9 448.7 40.9m48.5 60.2c-8.2 0-14.8 26.5-14.8 59.2s6.6 59.2 14.8 59.2S512 193 512 160.3s-6.6-59.2-14.8-59.2"/>
</svg>);
interface SettingsPageProps { interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void; onResetRequest?: (resetFn: () => void) => void;
@@ -118,7 +123,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
setTempSettings((prev) => ({ ...prev, tidalQuality: value })); setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
}; };
const handleQobuzQualityChange = (value: "6" | "7") => { const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
setTempSettings((prev) => ({ ...prev, qobuzQuality: value })); setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
}; };
const handleAutoQualityChange = async (value: "16" | "24") => { const handleAutoQualityChange = async (value: "16" | "24") => {
@@ -234,7 +239,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="downloader">Source</Label> <Label htmlFor="downloader">Source</Label>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ <Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
...prev, ...prev,
downloader: value, downloader: value,
}))}> }))}>
@@ -261,6 +266,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Amazon Music Amazon Music
</span> </span>
</SelectItem> </SelectItem>
<SelectItem value="deezer">
<span className="flex items-center">
<DeezerIcon />
Deezer
</span>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -273,6 +284,193 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="tidal-qobuz-amazon-deezer">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz-deezer-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal-amazon-deezer">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal-qobuz-deezer">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="deezer-tidal-qobuz-amazon">
<span className="flex items-center gap-1.5">
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="deezer-qobuz-amazon-tidal">
<span className="flex items-center gap-1.5">
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="deezer-amazon-tidal-qobuz">
<span className="flex items-center gap-1.5">
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz-deezer">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon-deezer">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-amazon-deezer">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz-deezer">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal-deezer">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="deezer-qobuz-amazon">
<span className="flex items-center gap-1.5">
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-deezer">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-deezer">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-deezer">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<DeezerIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="deezer-tidal">
<span className="flex items-center gap-1.5">
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="deezer-qobuz">
<span className="flex items-center gap-1.5">
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="deezer-amazon">
<span className="flex items-center gap-1.5">
<DeezerIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz"> <SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/> <TidalIcon className="fill-current"/>
@@ -313,62 +511,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<AmazonIcon className="fill-current"/> <AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/> <ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/> <QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon-qobuz">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-amazon-tidal">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal-qobuz">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz-tidal">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span> </span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@@ -403,19 +545,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="6">16-bit/44.1kHz</SelectItem> <SelectItem value="6">16-bit/44.1kHz</SelectItem>
<SelectItem value="7">24-bit/48kHz</SelectItem> <SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
</SelectContent> </SelectContent>
</Select>)} </Select>)}
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default"> {tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit - 24-bit/44.1kHz - 192kHz 16-bit - 24-bit/44.1kHz - 192kHz
</div>)} </div>)}
{tempSettings.downloader === "deezer" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit/44.1kHz
</div>)}
</div> </div>
{((tempSettings.downloader === "tidal" && {((tempSettings.downloader === "tidal" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") || tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" && (tempSettings.downloader === "qobuz" &&
tempSettings.qobuzQuality === "7") || tempSettings.qobuzQuality === "27") ||
(tempSettings.downloader === "auto" && (tempSettings.downloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2"> tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -452,6 +597,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedGenre: checked,
}))}/>
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
Embed Genre
</Label>
</div>
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({ <Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev, ...prev,
useSingleGenre: checked, useSingleGenre: checked,
@@ -459,7 +613,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal"> <Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
Use Single Genre Use Single Genre
</Label> </Label>
</div> </div>)}
</div> </div>
</div> </div>
</div>)} </div>)}
@@ -513,7 +667,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
.replace(/\{artist\}/g, "Kendrick Lamar, SZA") .replace(/\{artist\}/g, "Kendrick Lamar, SZA")
.replace(/\{album\}/g, "Black Panther") .replace(/\{album\}/g, "Black Panther")
.replace(/\{album_artist\}/g, "Kendrick Lamar") .replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{year\}/g, "2018")} .replace(/\{year\}/g, "2018")
.replace(/\{date\}/g, "2018-02-09")}
/ /
</span> </span>
</p>)} </p>)}
@@ -601,7 +756,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
.replace(/\{title\}/g, "All The Stars") .replace(/\{title\}/g, "All The Stars")
.replace(/\{track\}/g, "01") .replace(/\{track\}/g, "01")
.replace(/\{disc\}/g, "1") .replace(/\{disc\}/g, "1")
.replace(/\{year\}/g, "2018")} .replace(/\{year\}/g, "2018")
.replace(/\{date\}/g, "2018-02-09")}
.flac .flac
</span> </span>
</p>)} </p>)}
+2 -1
View File
@@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; 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"; import { usePreview } from "@/hooks/usePreview";
interface TrackInfoProps { interface TrackInfoProps {
track: TrackMetadata & { track: TrackMetadata & {
@@ -143,6 +143,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/> <TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/> <QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/> <AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)} </div>) : (<p>Check Availability</p>)}
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
+9 -1
View File
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; 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"; import { usePreview } from "@/hooks/usePreview";
interface TrackListProps { interface TrackListProps {
tracks: TrackMetadata[]; tracks: TrackMetadata[];
@@ -116,6 +116,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); 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 totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage; const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage; const endIndex = startIndex + itemsPerPage;
@@ -324,6 +331,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/> <TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/> <QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/> <AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)} </div>) : (<p>Check Availability</p>)}
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
+15 -7
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import { downloadCover } from "@/lib/api"; import { downloadCover } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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 { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
export function useCover() { export function useCover() {
@@ -29,12 +29,16 @@ export function useCover() {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
const yearValue = releaseDate?.substring(0, 4); 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 = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: position, track: position,
year: yearValue, year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -55,9 +59,9 @@ export function useCover() {
const response = await downloadCover({ const response = await downloadCover({
cover_url: coverUrl, cover_url: coverUrl,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName || "", album_name: albumName || "",
album_artist: albumArtist || "", album_artist: displayAlbumArtist || "",
release_date: releaseDate || "", release_date: releaseDate || "",
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
@@ -127,12 +131,16 @@ export function useCover() {
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
const yearValue = track.release_date?.substring(0, 4); 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 = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: track.album_name?.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), title: track.name?.replace(/\//g, placeholder),
track: trackPosition, track: trackPosition,
year: yearValue, year: yearValue,
date: track.release_date,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -153,9 +161,9 @@ export function useCover() {
const response = await downloadCover({ const response = await downloadCover({
cover_url: track.images, cover_url: track.images,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: displayArtist,
album_name: track.album_name, album_name: track.album_name,
album_artist: track.album_artist, album_artist: displayAlbumArtist,
release_date: track.release_date, release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
+156 -17
View File
@@ -2,16 +2,9 @@ import { useState, useRef } from "react";
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api"; import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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 { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; 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 { interface CheckFileExistenceRequest {
spotify_id: string; spotify_id: string;
track_name: string; track_name: string;
@@ -95,6 +88,7 @@ export function useDownload(region: string) {
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: trackNumberForTemplate, track: trackNumberForTemplate,
year: yearValue, year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -166,9 +160,10 @@ export function useDownload(region: string) {
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" }; let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const is24Bit = (settings.autoQuality || "24") === "24"; const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6"; const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) { for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) { if (s === "tidal" && streamingURLs?.tidal_url) {
try { try {
@@ -202,16 +197,20 @@ export function useDownload(region: string) {
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre, use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`); logger.success(`tidal: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`tidal failed, trying next...`); logger.warning(`tidal failed, trying next...`);
} }
catch (err) { catch (err) {
logger.error(`tidal error: ${err}`); logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
@@ -244,16 +243,20 @@ export function useDownload(region: string) {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre, use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`); logger.success(`amazon: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`amazon failed, trying next...`); logger.warning(`amazon failed, trying next...`);
} }
catch (err) { catch (err) {
logger.error(`amazon error: ${err}`); logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
@@ -286,23 +289,76 @@ export function useDownload(region: string) {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre, use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`); logger.success(`qobuz: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`qobuz failed, trying next...`); logger.warning(`qobuz failed, trying next...`);
} }
catch (err) { catch (err) {
logger.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,
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) }; lastResponse = { success: false, error: String(err) };
} }
} }
} }
if (itemID) { if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); 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; return lastResponse;
} }
@@ -314,8 +370,12 @@ export function useDownload(region: string) {
else if (service === "qobuz") { else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6"; audioFormat = settings.qobuzQuality || "6";
} }
else if (service === "deezer") {
audioFormat = "flac";
}
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
const singleServiceResponse = await downloadTrack({ const singleServiceResponse = await downloadTrack({
service: service as "tidal" | "qobuz" | "amazon", service: service as "tidal" | "qobuz" | "amazon" | "deezer",
query, query,
track_name: trackName, track_name: trackName,
artist_name: displayArtist, artist_name: displayArtist,
@@ -341,6 +401,7 @@ export function useDownload(region: string) {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_single_genre: settings.useSingleGenre, use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (!singleServiceResponse.success && itemID) { if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
@@ -389,6 +450,7 @@ export function useDownload(region: string) {
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: trackNumberForTemplate, track: trackNumberForTemplate,
year: yearValue, year: yearValue,
date: releaseDate,
playlist: folderName?.replace(/\//g, placeholder), playlist: folderName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -421,12 +483,14 @@ export function useDownload(region: string) {
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" }; let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const is24Bit = (settings.autoQuality || "24") === "24"; const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6"; const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) { for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) { if (s === "tidal" && streamingURLs?.tidal_url) {
try { try {
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
service: "tidal", service: "tidal",
query, query,
@@ -456,19 +520,26 @@ export function useDownload(region: string) {
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre, use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`tidal failed, trying next...`);
} }
catch (err) { catch (err) {
console.error("Tidal error:", err); logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
else if (s === "amazon" && streamingURLs?.amazon_url) { else if (s === "amazon" && streamingURLs?.amazon_url) {
try { try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
service: "amazon", service: "amazon",
query, query,
@@ -496,19 +567,26 @@ export function useDownload(region: string) {
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre, use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`amazon failed, trying next...`);
} }
catch (err) { catch (err) {
console.error("Amazon error:", err); logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
else if (s === "qobuz") { else if (s === "qobuz") {
try { try {
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
service: "qobuz", service: "qobuz",
query, query,
@@ -537,21 +615,76 @@ export function useDownload(region: string) {
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly, use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre, use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`qobuz failed, trying next...`);
} }
catch (err) { 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) }; lastResponse = { success: false, error: String(err) };
} }
} }
} }
if (!lastResponse.success && itemID) { if (!lastResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); 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; return lastResponse;
} }
@@ -563,8 +696,11 @@ export function useDownload(region: string) {
else if (service === "qobuz") { else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6"; audioFormat = settings.qobuzQuality || "6";
} }
else if (service === "deezer") {
audioFormat = "flac";
}
const singleServiceResponse = await downloadTrack({ const singleServiceResponse = await downloadTrack({
service: service as "tidal" | "qobuz" | "amazon", service: service as "tidal" | "qobuz" | "amazon" | "deezer",
query, query,
track_name: trackName, track_name: trackName,
artist_name: displayArtist, artist_name: displayArtist,
@@ -589,6 +725,9 @@ export function useDownload(region: string) {
spotify_total_discs: spotifyTotalDiscs, spotify_total_discs: spotifyTotalDiscs,
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
}); });
if (!singleServiceResponse.success && itemID) { if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
+15 -7
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import { downloadLyrics } from "@/lib/api"; import { downloadLyrics } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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 { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
export function useLyrics() { export function useLyrics() {
@@ -26,12 +26,16 @@ export function useLyrics() {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
const yearValue = releaseDate?.substring(0, 4); 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 = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: position, track: position,
year: yearValue, year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -53,9 +57,9 @@ export function useLyrics() {
const response = await downloadLyrics({ const response = await downloadLyrics({
spotify_id: spotifyId, spotify_id: spotifyId,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist,
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: releaseDate, release_date: releaseDate,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
@@ -123,12 +127,16 @@ export function useLyrics() {
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
const yearValue = track.release_date?.substring(0, 4); 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 = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: track.album_name?.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), title: track.name?.replace(/\//g, placeholder),
track: trackPosition, track: trackPosition,
year: yearValue, year: yearValue,
date: track.release_date,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
@@ -149,9 +157,9 @@ export function useLyrics() {
const response = await downloadLyrics({ const response = await downloadLyrics({
spotify_id: id, spotify_id: id,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: displayArtist,
album_name: track.album_name, album_name: track.album_name,
album_artist: track.album_artist, album_artist: displayAlbumArtist,
release_date: track.release_date, release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
+13 -11
View File
@@ -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 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 { export interface Settings {
downloadPath: string; downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon"; downloader: "auto" | "tidal" | "qobuz" | "amazon" | "deezer";
theme: string; theme: string;
themeMode: "auto" | "light" | "dark"; themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily; fontFamily: FontFamily;
@@ -21,9 +21,9 @@ export interface Settings {
embedMaxQualityCover: boolean; embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS"; operatingSystem: "Windows" | "linux/MacOS";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7"; qobuzQuality: "6" | "7" | "27";
amazonQuality: "original"; 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"; autoQuality: "16" | "24";
allowFallback: boolean; allowFallback: boolean;
useSpotFetchAPI: boolean; useSpotFetchAPI: boolean;
@@ -32,6 +32,7 @@ export interface Settings {
createM3u8File: boolean; createM3u8File: boolean;
useFirstArtistOnly: boolean; useFirstArtistOnly: boolean;
useSingleGenre: boolean; useSingleGenre: boolean;
embedGenre: boolean;
} }
export const FOLDER_PRESETS: Record<FolderPreset, { export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
@@ -79,6 +80,7 @@ export const TEMPLATE_VARIABLES = [
{ key: "{track}", description: "Track number", example: "01" }, { key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" }, { key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" }, { key: "{year}", description: "Release year", example: "2014" },
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
]; ];
function detectOS(): "Windows" | "linux/MacOS" { function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase(); const platform = window.navigator.platform.toLowerCase();
@@ -105,7 +107,7 @@ export const DEFAULT_SETTINGS: Settings = {
tidalQuality: "LOSSLESS", tidalQuality: "LOSSLESS",
qobuzQuality: "6", qobuzQuality: "6",
amazonQuality: "original", amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon", autoOrder: "tidal-qobuz-amazon-deezer",
autoQuality: "16", autoQuality: "16",
allowFallback: true, allowFallback: true,
useSpotFetchAPI: false, useSpotFetchAPI: false,
@@ -113,7 +115,8 @@ export const DEFAULT_SETTINGS: Settings = {
createPlaylistFolder: true, createPlaylistFolder: true,
createM3u8File: false, createM3u8File: false,
useFirstArtistOnly: false, useFirstArtistOnly: false,
useSingleGenre: false useSingleGenre: false,
embedGenre: true
}; };
export const FONT_OPTIONS: { export const FONT_OPTIONS: {
value: FontFamily; value: FontFamily;
@@ -208,9 +211,6 @@ function getSettingsFromLocalStorage(): Settings {
if (!('qobuzQuality' in parsed)) { if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6"; parsed.qobuzQuality = "6";
} }
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original"; parsed.amazonQuality = "original";
} }
@@ -287,9 +287,6 @@ export async function loadSettings(): Promise<Settings> {
if (!('qobuzQuality' in parsed)) { if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6"; parsed.qobuzQuality = "6";
} }
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original"; parsed.amazonQuality = "original";
} }
@@ -314,6 +311,9 @@ export async function loadSettings(): Promise<Settings> {
if (!('useSingleGenre' in parsed)) { if (!('useSingleGenre' in parsed)) {
parsed.useSingleGenre = false; parsed.useSingleGenre = false;
} }
if (!('embedGenre' in parsed)) {
parsed.embedGenre = true;
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!; return cachedSettings!;
} }
@@ -339,6 +339,7 @@ export interface TemplateData {
track?: number; track?: number;
disc?: number; disc?: number;
year?: string; year?: string;
date?: string;
playlist?: string; playlist?: string;
} }
export function parseTemplate(template: string, data: TemplateData): 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(/\{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(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{year\}/g, data.year || "0000"); 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 || ""); result = result.replace(/\{playlist\}/g, data.playlist || "");
return result; return result;
} }
+7
View File
@@ -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();
}
+4 -1
View File
@@ -108,7 +108,7 @@ export interface ArtistResponse {
} }
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse; export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
export interface DownloadRequest { export interface DownloadRequest {
service: "tidal" | "qobuz" | "amazon"; service: "tidal" | "qobuz" | "amazon" | "deezer";
query?: string; query?: string;
track_name?: string; track_name?: string;
artist_name?: string; artist_name?: string;
@@ -139,6 +139,7 @@ export interface DownloadRequest {
spotify_url?: string; spotify_url?: string;
use_first_artist_only?: boolean; use_first_artist_only?: boolean;
use_single_genre?: boolean; use_single_genre?: boolean;
embed_genre?: boolean;
} }
export interface DownloadResponse { export interface DownloadResponse {
success: boolean; success: boolean;
@@ -203,9 +204,11 @@ export interface TrackAvailability {
tidal: boolean; tidal: boolean;
amazon: boolean; amazon: boolean;
qobuz: boolean; qobuz: boolean;
deezer: boolean;
tidal_url?: string; tidal_url?: string;
amazon_url?: string; amazon_url?: string;
qobuz_url?: string; qobuz_url?: string;
deezer_url?: string;
} }
export interface CoverDownloadRequest { export interface CoverDownloadRequest {
cover_url: string; cover_url: string;