v7.1.0
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
<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.
|
||||
|
||||

|
||||

|
||||
@@ -94,7 +94,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
||||
|
||||
## API Credits
|
||||
|
||||
[MusicBrainz](https://musicbrainz.org) · [Spotify Lyrics API](https://github.akashrchandran.in/spotify-lyrics-api) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
||||
[MusicBrainz](https://musicbrainz.org) · [Spotify Lyrics API](https://github.akashrchandran.in/spotify-lyrics-api) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) · [yoinkify.lol](https://yoinkify.lol)
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
|
||||
@@ -90,6 +90,7 @@ type DownloadRequest struct {
|
||||
AllowFallback bool `json:"allow_fallback"`
|
||||
UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"`
|
||||
UseSingleGenre bool `json:"use_single_genre,omitempty"`
|
||||
EmbedGenre bool `json:"embed_genre,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
@@ -368,25 +369,25 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
if req.ServiceURL != "" {
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre)
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} else {
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre)
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
if req.ServiceURL != "" {
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre)
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} else {
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre)
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||
if req.ServiceURL != "" {
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre)
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
} else {
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre)
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +400,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if quality == "" {
|
||||
quality = "6"
|
||||
}
|
||||
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre)
|
||||
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
|
||||
case "deezer":
|
||||
downloader := backend.NewDeezerDownloader()
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
|
||||
default:
|
||||
return DownloadResponse{
|
||||
@@ -480,11 +485,17 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
|
||||
meta, err := backend.GetTrackMetadata(fPath)
|
||||
if err == nil && meta != nil {
|
||||
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
|
||||
if meta.BitsPerSample > 0 {
|
||||
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
|
||||
} else if meta.Bitrate > 0 {
|
||||
quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0)
|
||||
} else if meta.SampleRate > 0 {
|
||||
quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0)
|
||||
}
|
||||
d := int(meta.Duration)
|
||||
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
|
||||
} else {
|
||||
|
||||
fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err)
|
||||
}
|
||||
|
||||
item := backend.HistoryItem{
|
||||
@@ -495,7 +506,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
DurationStr: durationStr,
|
||||
CoverURL: cover,
|
||||
Quality: quality,
|
||||
Format: format,
|
||||
Format: strings.ToUpper(format),
|
||||
Path: fPath,
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -111,7 +111,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://amz.afkarxyz.fun/api/track/%s", asin)
|
||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -259,7 +259,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str
|
||||
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool) (string, error) {
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -289,7 +289,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if spotifyURL != "" {
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
@@ -306,7 +306,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre); err == nil {
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
@@ -363,6 +363,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
||||
|
||||
if spotifyDiscNumber > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||
@@ -472,7 +473,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string,
|
||||
useFirstArtistOnly bool, useSingleGenre bool,
|
||||
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
|
||||
) (string, error) {
|
||||
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
@@ -480,5 +481,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre)
|
||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
+95
-24
@@ -4,6 +4,10 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-flac/go-flac"
|
||||
mewflac "github.com/mewkiz/flac"
|
||||
@@ -17,6 +21,7 @@ type AnalysisResult struct {
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
Bitrate int `json:"bit_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
@@ -168,40 +173,106 @@ func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(filepath)
|
||||
return GetMetadataWithFFprobe(filepath)
|
||||
}
|
||||
|
||||
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filepath)
|
||||
for i := 0; i < 5; i++ {
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
filePath,
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output))
|
||||
}
|
||||
|
||||
result := &AnalysisResult{
|
||||
FilePath: filepath,
|
||||
FileSize: fileInfo.Size(),
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) < 4 {
|
||||
return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
|
||||
}
|
||||
|
||||
if len(f.Meta) > 0 {
|
||||
streamInfo := f.Meta[0]
|
||||
if streamInfo.Type == flac.StreamInfo {
|
||||
data := streamInfo.Data
|
||||
if len(data) >= 18 {
|
||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||
uint64(data[14])<<24 |
|
||||
uint64(data[15])<<16 |
|
||||
uint64(data[16])<<8 |
|
||||
uint64(data[17])
|
||||
res := &AnalysisResult{
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
if result.SampleRate > 0 {
|
||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
||||
}
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
res.FileSize = info.Size()
|
||||
}
|
||||
|
||||
infoMap := make(map[string]string)
|
||||
|
||||
args = []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
filePath,
|
||||
}
|
||||
cmd = exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
lines = strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||
return result, nil
|
||||
|
||||
if val, ok := infoMap["sample_rate"]; ok {
|
||||
s, _ := strconv.Atoi(val)
|
||||
res.SampleRate = uint32(s)
|
||||
}
|
||||
if val, ok := infoMap["channels"]; ok {
|
||||
c, _ := strconv.Atoi(val)
|
||||
res.Channels = uint8(c)
|
||||
}
|
||||
if val, ok := infoMap["duration"]; ok {
|
||||
d, _ := strconv.ParseFloat(val, 64)
|
||||
res.Duration = d
|
||||
}
|
||||
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
|
||||
br, _ := strconv.Atoi(val)
|
||||
res.Bitrate = br
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
if bits == 0 {
|
||||
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
}
|
||||
|
||||
res.BitsPerSample = uint8(bits)
|
||||
if bits > 0 {
|
||||
res.BitDepth = fmt.Sprintf("%d-bit", bits)
|
||||
} else {
|
||||
res.BitDepth = "Unknown"
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -332,6 +332,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
||||
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||
|
||||
@@ -33,6 +33,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
||||
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
||||
|
||||
|
||||
@@ -381,6 +381,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
|
||||
@@ -54,9 +54,13 @@ type MusicBrainzRecordingResponse struct {
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool) (Metadata, error) {
|
||||
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
|
||||
var meta Metadata
|
||||
|
||||
if !embedGenre {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
if isrc == "" {
|
||||
return meta, fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
+6
-6
@@ -167,7 +167,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
standardAPIs := []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
"https://qobuz.squid.wtf/api/download-music?track_id=",
|
||||
}
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
@@ -319,6 +318,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
@@ -353,7 +353,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) {
|
||||
func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
var deezerISRC string
|
||||
if spotifyID != "" {
|
||||
songlinkClient := NewSongLinkClient()
|
||||
@@ -366,17 +366,17 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF
|
||||
return "", fmt.Errorf("spotify ID is required for Qobuz download")
|
||||
}
|
||||
|
||||
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre)
|
||||
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) {
|
||||
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||
|
||||
metaChan := make(chan Metadata, 1)
|
||||
if deezerISRC != "" {
|
||||
if embedGenre && deezerISRC != "" {
|
||||
go func() {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre); err == nil {
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
metaChan <- fetchedMeta
|
||||
} else {
|
||||
|
||||
@@ -28,9 +28,11 @@ type TrackAvailability struct {
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
Deezer bool `json:"deezer"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
}
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
@@ -279,6 +281,8 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
deezerURL := deezerLink.URL
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerURL
|
||||
|
||||
deezerISRC, err := getDeezerISRC(deezerURL)
|
||||
if err == nil && deezerISRC != "" {
|
||||
|
||||
+9
-12
@@ -79,13 +79,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
apis := []string{
|
||||
"https://api.monochrome.tf",
|
||||
"https://arran.monochrome.tf",
|
||||
"https://triton.squid.wtf",
|
||||
"https://hifi-one.spotisaver.net",
|
||||
"https://hifi-two.spotisaver.net",
|
||||
"https://tidal.kinoplus.online",
|
||||
"https://tidal-api.binimum.org",
|
||||
}
|
||||
return apis, nil
|
||||
}
|
||||
@@ -448,7 +444,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("directory error: %w", err)
|
||||
@@ -508,7 +504,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if spotifyURL != "" {
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
@@ -525,7 +521,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre); err == nil {
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
@@ -601,7 +597,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
apis, err := t.GetAvailableAPIs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
@@ -666,7 +662,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResultFallback, 1)
|
||||
if spotifyURL != "" {
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResultFallback{}
|
||||
var isrc string
|
||||
@@ -683,7 +679,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre); err == nil {
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
@@ -760,14 +756,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool) (string, error) {
|
||||
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
||||
}
|
||||
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre)
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
type SegmentTemplate struct {
|
||||
@@ -1023,6 +1019,7 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
|
||||
@@ -317,7 +317,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
<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"/>)}
|
||||
</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">
|
||||
{artistInfo.rank && (<>
|
||||
<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>
|
||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
|
||||
</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">
|
||||
{artistInfo.rank && (<>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
||||
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
|
||||
import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
interface AudioFile {
|
||||
@@ -152,6 +152,27 @@ export function AudioConverterPage() {
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
const selectedFolder = await SelectFolder("");
|
||||
if (selectedFolder) {
|
||||
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||
if (folderFiles && folderFiles.length > 0) {
|
||||
addFiles(folderFiles.map((f) => f.path));
|
||||
}
|
||||
else {
|
||||
toast.info("No audio files found", {
|
||||
description: "No FLAC or MP3 files found in the selected folder.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Folder Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||
});
|
||||
}
|
||||
};
|
||||
const addFiles = useCallback(async (paths: string[]) => {
|
||||
const validExtensions = [".mp3", ".flac"];
|
||||
const m4aFiles = paths.filter((path) => {
|
||||
@@ -298,7 +319,11 @@ export function AudioConverterPage() {
|
||||
{files.length > 0 && (<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||
<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 variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
@@ -329,10 +354,16 @@ export function AudioConverterPage() {
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</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">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
@@ -303,7 +303,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<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>
|
||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||
</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="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>);
|
||||
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="downloaded">Downloaded</SelectItem>
|
||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||
<SelectItem value="failed">Failed Downloads</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>);
|
||||
|
||||
@@ -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="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>);
|
||||
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 {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
@@ -118,7 +123,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||
};
|
||||
const handleQobuzQualityChange = (value: "6" | "7") => {
|
||||
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||
};
|
||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||
@@ -234,7 +239,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<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,
|
||||
downloader: value,
|
||||
}))}>
|
||||
@@ -261,6 +266,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
Amazon Music
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer">
|
||||
<span className="flex items-center">
|
||||
<DeezerIcon />
|
||||
Deezer
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -273,6 +284,193 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<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">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
@@ -313,62 +511,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<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>
|
||||
</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>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -403,19 +545,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
|
||||
<SelectItem value="7">24-bit/48kHz</SelectItem>
|
||||
<SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</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">
|
||||
16-bit - 24-bit/44.1kHz - 192kHz
|
||||
</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>
|
||||
|
||||
{((tempSettings.downloader === "tidal" &&
|
||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||
(tempSettings.downloader === "qobuz" &&
|
||||
tempSettings.qobuzQuality === "7") ||
|
||||
tempSettings.qobuzQuality === "27") ||
|
||||
(tempSettings.downloader === "auto" &&
|
||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -452,14 +597,23 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
useSingleGenre: checked,
|
||||
embedGenre: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
||||
Use Single Genre
|
||||
<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) => ({
|
||||
...prev,
|
||||
useSingleGenre: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
||||
Use Single Genre
|
||||
</Label>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
@@ -513,7 +667,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
||||
.replace(/\{album\}/g, "Black Panther")
|
||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||
.replace(/\{year\}/g, "2018")}
|
||||
.replace(/\{year\}/g, "2018")
|
||||
.replace(/\{date\}/g, "2018-02-09")}
|
||||
/
|
||||
</span>
|
||||
</p>)}
|
||||
@@ -601,7 +756,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
.replace(/\{title\}/g, "All The Stars")
|
||||
.replace(/\{track\}/g, "01")
|
||||
.replace(/\{disc\}/g, "1")
|
||||
.replace(/\{year\}/g, "2018")}
|
||||
.replace(/\{year\}/g, "2018")
|
||||
.replace(/\{date\}/g, "2018-02-09")}
|
||||
.flac
|
||||
</span>
|
||||
</p>)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & {
|
||||
@@ -143,6 +143,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
<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"}`}/>
|
||||
<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>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
@@ -116,6 +116,13 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
else if (sortBy === "failed") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
|
||||
const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
|
||||
return (bFailed ? 1 : 0) - (aFailed ? 1 : 0);
|
||||
});
|
||||
}
|
||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
@@ -324,6 +331,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
<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"}`}/>
|
||||
<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>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
export function useCover() {
|
||||
@@ -29,12 +29,16 @@ export function useCover() {
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const yearValue = releaseDate?.substring(0, 4);
|
||||
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
|
||||
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist;
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
artist: displayArtist?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: yearValue,
|
||||
date: releaseDate,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -55,9 +59,9 @@ export function useCover() {
|
||||
const response = await downloadCover({
|
||||
cover_url: coverUrl,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
artist_name: displayArtist,
|
||||
album_name: albumName || "",
|
||||
album_artist: albumArtist || "",
|
||||
album_artist: displayAlbumArtist || "",
|
||||
release_date: releaseDate || "",
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
@@ -127,12 +131,16 @@ export function useCover() {
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
const yearValue = track.release_date?.substring(0, 4);
|
||||
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
artist: displayArtist?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
year: yearValue,
|
||||
date: track.release_date,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -153,9 +161,9 @@ export function useCover() {
|
||||
const response = await downloadCover({
|
||||
cover_url: track.images,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
artist_name: displayArtist,
|
||||
album_name: track.album_name,
|
||||
album_artist: track.album_artist,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: track.release_date,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
|
||||
@@ -2,16 +2,9 @@ import { useState, useRef } from "react";
|
||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
function getFirstArtist(artistString: string): string {
|
||||
if (!artistString)
|
||||
return artistString;
|
||||
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
|
||||
const parts = artistString.split(delimiters);
|
||||
return parts[0].trim();
|
||||
}
|
||||
interface CheckFileExistenceRequest {
|
||||
spotify_id: string;
|
||||
track_name: string;
|
||||
@@ -95,6 +88,7 @@ export function useDownload(region: string) {
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: trackNumberForTemplate,
|
||||
year: yearValue,
|
||||
date: releaseDate,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -166,9 +160,10 @@ export function useDownload(region: string) {
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||
const fallbackErrors: string[] = [];
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
const qobuzQuality = is24Bit ? "7" : "6";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
@@ -202,16 +197,20 @@ export function useDownload(region: string) {
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`tidal failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -244,16 +243,20 @@ export function useDownload(region: string) {
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`amazon failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`amazon error: ${err}`);
|
||||
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -286,23 +289,76 @@ export function useDownload(region: string) {
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`qobuz failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`qobuz error: ${err}`);
|
||||
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
else if (s === "deezer") {
|
||||
try {
|
||||
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "deezer",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
album_name: albumName,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: finalReleaseDate || releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: "flac",
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
spotify_total_discs: spotifyTotalDiscs,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`deezer: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Deezer] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`deezer failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`deezer error: ${err}`);
|
||||
fallbackErrors.push(`[Deezer] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (itemID) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
||||
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
||||
await MarkDownloadItemFailed(itemID, finalError);
|
||||
}
|
||||
return lastResponse;
|
||||
}
|
||||
@@ -314,8 +370,12 @@ export function useDownload(region: string) {
|
||||
else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
}
|
||||
else if (service === "deezer") {
|
||||
audioFormat = "flac";
|
||||
}
|
||||
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
service: service as "tidal" | "qobuz" | "amazon",
|
||||
service: service as "tidal" | "qobuz" | "amazon" | "deezer",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
@@ -341,6 +401,7 @@ export function useDownload(region: string) {
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (!singleServiceResponse.success && itemID) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
@@ -389,6 +450,7 @@ export function useDownload(region: string) {
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: trackNumberForTemplate,
|
||||
year: yearValue,
|
||||
date: releaseDate,
|
||||
playlist: folderName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -421,12 +483,14 @@ export function useDownload(region: string) {
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||
const fallbackErrors: string[] = [];
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
const qobuzQuality = is24Bit ? "7" : "6";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "tidal",
|
||||
query,
|
||||
@@ -456,19 +520,26 @@ export function useDownload(region: string) {
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`tidal failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Tidal error:", err);
|
||||
logger.error(`tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||
try {
|
||||
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "amazon",
|
||||
query,
|
||||
@@ -496,19 +567,26 @@ export function useDownload(region: string) {
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`amazon failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Amazon error:", err);
|
||||
logger.error(`amazon error: ${err}`);
|
||||
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
else if (s === "qobuz") {
|
||||
try {
|
||||
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "qobuz",
|
||||
query,
|
||||
@@ -537,21 +615,76 @@ export function useDownload(region: string) {
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`qobuz failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Qobuz error:", err);
|
||||
logger.error(`qobuz error: ${err}`);
|
||||
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
else if (s === "deezer") {
|
||||
try {
|
||||
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "deezer",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
album_name: albumName,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: finalReleaseDate || releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position: trackNumberForTemplate,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: "flac",
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
spotify_total_discs: spotifyTotalDiscs,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`deezer: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Deezer] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`deezer failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`deezer error: ${err}`);
|
||||
fallbackErrors.push(`[Deezer] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!lastResponse.success && itemID) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
||||
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
||||
await MarkDownloadItemFailed(itemID, finalError);
|
||||
}
|
||||
return lastResponse;
|
||||
}
|
||||
@@ -563,8 +696,11 @@ export function useDownload(region: string) {
|
||||
else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
}
|
||||
else if (service === "deezer") {
|
||||
audioFormat = "flac";
|
||||
}
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
service: service as "tidal" | "qobuz" | "amazon",
|
||||
service: service as "tidal" | "qobuz" | "amazon" | "deezer",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
@@ -589,6 +725,9 @@ export function useDownload(region: string) {
|
||||
spotify_total_discs: spotifyTotalDiscs,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (!singleServiceResponse.success && itemID) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
|
||||
import { downloadLyrics } from "@/lib/api";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
export function useLyrics() {
|
||||
@@ -26,12 +26,16 @@ export function useLyrics() {
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const yearValue = releaseDate?.substring(0, 4);
|
||||
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
|
||||
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist;
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
artist: displayArtist?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: yearValue,
|
||||
date: releaseDate,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -53,9 +57,9 @@ export function useLyrics() {
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: spotifyId,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
artist_name: displayArtist,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: releaseDate,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
@@ -123,12 +127,16 @@ export function useLyrics() {
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
const yearValue = track.release_date?.substring(0, 4);
|
||||
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
artist: displayArtist?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
year: yearValue,
|
||||
date: track.release_date,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -149,9 +157,9 @@ export function useLyrics() {
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: id,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
artist_name: displayArtist,
|
||||
album_name: track.album_name,
|
||||
album_artist: track.album_artist,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: track.release_date,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
|
||||
@@ -4,7 +4,7 @@ export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-ar
|
||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon" | "deezer";
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
@@ -21,9 +21,9 @@ export interface Settings {
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
amazonQuality: "original";
|
||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
|
||||
autoOrder: "tidal-qobuz-amazon-deezer" | "tidal-qobuz-deezer-amazon" | "tidal-amazon-qobuz-deezer" | "tidal-amazon-deezer-qobuz" | "tidal-deezer-qobuz-amazon" | "tidal-deezer-amazon-qobuz" | "qobuz-tidal-amazon-deezer" | "qobuz-tidal-deezer-amazon" | "qobuz-amazon-tidal-deezer" | "qobuz-amazon-deezer-tidal" | "qobuz-deezer-tidal-amazon" | "qobuz-deezer-amazon-tidal" | "amazon-tidal-qobuz-deezer" | "amazon-tidal-deezer-qobuz" | "amazon-qobuz-tidal-deezer" | "amazon-qobuz-deezer-tidal" | "amazon-deezer-tidal-qobuz" | "amazon-deezer-qobuz-tidal" | "deezer-tidal-qobuz-amazon" | "deezer-tidal-amazon-qobuz" | "deezer-qobuz-tidal-amazon" | "deezer-qobuz-amazon-tidal" | "deezer-amazon-tidal-qobuz" | "deezer-amazon-qobuz-tidal" | string;
|
||||
autoQuality: "16" | "24";
|
||||
allowFallback: boolean;
|
||||
useSpotFetchAPI: boolean;
|
||||
@@ -32,6 +32,7 @@ export interface Settings {
|
||||
createM3u8File: boolean;
|
||||
useFirstArtistOnly: boolean;
|
||||
useSingleGenre: boolean;
|
||||
embedGenre: boolean;
|
||||
}
|
||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||
label: string;
|
||||
@@ -79,6 +80,7 @@ export const TEMPLATE_VARIABLES = [
|
||||
{ key: "{track}", description: "Track number", example: "01" },
|
||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||
{ key: "{year}", description: "Release year", example: "2014" },
|
||||
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
|
||||
];
|
||||
function detectOS(): "Windows" | "linux/MacOS" {
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
@@ -105,7 +107,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "original",
|
||||
autoOrder: "tidal-qobuz-amazon",
|
||||
autoOrder: "tidal-qobuz-amazon-deezer",
|
||||
autoQuality: "16",
|
||||
allowFallback: true,
|
||||
useSpotFetchAPI: false,
|
||||
@@ -113,7 +115,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
createPlaylistFolder: true,
|
||||
createM3u8File: false,
|
||||
useFirstArtistOnly: false,
|
||||
useSingleGenre: false
|
||||
useSingleGenre: false,
|
||||
embedGenre: true
|
||||
};
|
||||
export const FONT_OPTIONS: {
|
||||
value: FontFamily;
|
||||
@@ -208,9 +211,6 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (parsed.qobuzQuality === "27") {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "original";
|
||||
}
|
||||
@@ -287,9 +287,6 @@ export async function loadSettings(): Promise<Settings> {
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (parsed.qobuzQuality === "27") {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "original";
|
||||
}
|
||||
@@ -314,6 +311,9 @@ export async function loadSettings(): Promise<Settings> {
|
||||
if (!('useSingleGenre' in parsed)) {
|
||||
parsed.useSingleGenre = false;
|
||||
}
|
||||
if (!('embedGenre' in parsed)) {
|
||||
parsed.embedGenre = true;
|
||||
}
|
||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return cachedSettings!;
|
||||
}
|
||||
@@ -339,6 +339,7 @@ export interface TemplateData {
|
||||
track?: number;
|
||||
disc?: number;
|
||||
year?: string;
|
||||
date?: string;
|
||||
playlist?: string;
|
||||
}
|
||||
export function parseTemplate(template: string, data: TemplateData): string {
|
||||
@@ -352,6 +353,7 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
||||
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
||||
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||
result = result.replace(/\{date\}/g, data.date || "0000-00-00");
|
||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface ArtistResponse {
|
||||
}
|
||||
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
|
||||
export interface DownloadRequest {
|
||||
service: "tidal" | "qobuz" | "amazon";
|
||||
service: "tidal" | "qobuz" | "amazon" | "deezer";
|
||||
query?: string;
|
||||
track_name?: string;
|
||||
artist_name?: string;
|
||||
@@ -139,6 +139,7 @@ export interface DownloadRequest {
|
||||
spotify_url?: string;
|
||||
use_first_artist_only?: boolean;
|
||||
use_single_genre?: boolean;
|
||||
embed_genre?: boolean;
|
||||
}
|
||||
export interface DownloadResponse {
|
||||
success: boolean;
|
||||
@@ -203,9 +204,11 @@ export interface TrackAvailability {
|
||||
tidal: boolean;
|
||||
amazon: boolean;
|
||||
qobuz: boolean;
|
||||
deezer: boolean;
|
||||
tidal_url?: string;
|
||||
amazon_url?: string;
|
||||
qobuz_url?: string;
|
||||
deezer_url?: string;
|
||||
}
|
||||
export interface CoverDownloadRequest {
|
||||
cover_url: string;
|
||||
|
||||
Reference in New Issue
Block a user