This commit is contained in:
afkarxyz
2026-01-14 07:36:14 +07:00
parent 2fc08de757
commit 4ee252f438
9 changed files with 433 additions and 218 deletions
+2 -2
View File
@@ -292,7 +292,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
downloader := backend.NewAmazonDownloader() downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, 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) 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.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
} else { } else {
if req.SpotifyID == "" { if req.SpotifyID == "" {
return DownloadResponse{ return DownloadResponse{
@@ -300,7 +300,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Error: "Spotify ID is required for Amazon Music", Error: "Spotify ID is required for Amazon Music",
}, fmt.Errorf("spotify ID is required for Amazon Music") }, fmt.Errorf("spotify ID is required for Amazon Music")
} }
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, 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) filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, 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)
} }
case "tidal": case "tidal":
+209 -5
View File
@@ -1,12 +1,15 @@
package backend package backend
import ( import (
"bytes"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -44,6 +47,22 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"` } `json:"current"`
} }
type LucidaLoadResponse struct {
Success bool `json:"success"`
Server string `json:"server"`
Handoff string `json:"handoff"`
Error string `json:"error"`
}
type LucidaStatusResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Progress struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progress"`
}
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{ return &AmazonDownloader{
client: &http.Client{ client: &http.Client{
@@ -175,8 +194,193 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return amazonURL, nil return amazonURL, nil
} }
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) { func (a *AmazonDownloader) extractData(html string, patterns []string) string {
for _, p := range patterns {
re := regexp.MustCompile(p)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
}
return ""
}
func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality string) (string, error) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
jar, _ := cookiejar.New(nil)
client := &http.Client{
Transport: tr,
Jar: jar,
Timeout: 120 * time.Second,
}
userAgent := a.getRandomUserAgent()
fmt.Printf("Initializing lucida for Amazon Music... (Target: %s)\n", amazonURL)
lucidaBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vP3VybD0lcyZjb3VudHJ5PWF1dG8=")
lucidaURL := fmt.Sprintf(string(lucidaBase), url.QueryEscape(amazonURL))
req, _ := http.NewRequest("GET", lucidaURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
html := string(bodyBytes)
token := a.extractData(html, []string{`token:"([^"]+)"`, `"token"\s*:\s*"([^"]+)"`})
streamURL := a.extractData(html, []string{`"url":"([^"]+)"`, `url:"([^"]+)"`})
expiry := a.extractData(html, []string{`tokenExpiry:(\d+)`, `"tokenExpiry"\s*:\s*(\d+)`})
if token == "" || streamURL == "" {
errorMsg := a.extractData(html, []string{`error:"([^"]+)"`, `"error"\s*:\s*"([^"]+)"`})
if errorMsg != "" {
return "", fmt.Errorf("lucida error: %s", errorMsg)
}
return "", fmt.Errorf("could not extract required data from lucida")
}
decodedToken := token
if secondBase64, err := base64.StdEncoding.DecodeString(token); err == nil {
if firstBase64, err := base64.StdEncoding.DecodeString(string(secondBase64)); err == nil {
decodedToken = string(firstBase64)
}
}
streamURL = strings.ReplaceAll(streamURL, `\/`, `/`)
fmt.Printf("Fetching Amazon stream via Lucida...\n")
loadPayload := map[string]interface{}{
"account": map[string]string{"id": "auto", "type": "country"},
"compat": "false", "downscale": "original", "handoff": true,
"metadata": true, "private": true,
"token": map[string]interface{}{"primary": decodedToken, "expiry": expiry},
"upload": map[string]bool{"enabled": false},
"url": streamURL,
}
payloadBytes, _ := json.Marshal(loadPayload)
loadAPI, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vYXBpL2xvYWQ/dXJsPS9hcGkvZmV0Y2gvc3RyZWFtL3Yy")
req, _ = http.NewRequest("POST", string(loadAPI), bytes.NewBuffer(payloadBytes))
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Content-Type", "application/json")
for _, cookie := range client.Jar.Cookies(req.URL) {
if cookie.Name == "csrf_token" {
req.Header.Set("X-CSRF-Token", cookie.Value)
}
}
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var loadData LucidaLoadResponse
json.NewDecoder(resp.Body).Decode(&loadData)
if !loadData.Success {
return "", fmt.Errorf("lucida load request failed: %s", loadData.Error)
}
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
completionBase, _ := base64.StdEncoding.DecodeString("Lmx1Y2lkYS50by9hcGkvZmV0Y2gvcmVxdWVzdC8=")
completionURL := fmt.Sprintf("%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff)
fmt.Println("Processing on Lucida server...")
var finalStatus LucidaStatusResponse
for {
req, _ = http.NewRequest("GET", completionURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err = client.Do(req)
if err != nil {
return "", err
}
json.NewDecoder(resp.Body).Decode(&finalStatus)
resp.Body.Close()
if finalStatus.Status == "completed" {
fmt.Println("\nTrack processing completed!")
break
} else if finalStatus.Status == "error" {
return "", fmt.Errorf("lucida processing failed: %s", finalStatus.Message)
} else if finalStatus.Progress.Total > 0 {
percent := (finalStatus.Progress.Current * 100) / finalStatus.Progress.Total
fmt.Printf("\rLucida Progress: %d%%", percent)
}
time.Sleep(2 * time.Second)
}
downloadSuffix, _ := base64.StdEncoding.DecodeString("L2Rvd25sb2Fk")
downloadURL := fmt.Sprintf("%s%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff, string(downloadSuffix))
req, _ = http.NewRequest("GET", downloadURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("lucida download failed with status %d", resp.StatusCode)
}
fileName := "track.flac"
contentDisp := resp.Header.Get("Content-Disposition")
if contentDisp != "" {
re := regexp.MustCompile(`filename[*]?=([^;]+)`)
if matches := re.FindStringSubmatch(contentDisp); len(matches) > 1 {
rawName := strings.Trim(matches[1], `"'`)
if strings.HasPrefix(rawName, "UTF-8''") {
decodedName, _ := url.PathUnescape(rawName[7:])
fileName = decodedName
} else {
fileName = rawName
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
}
}
filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath)
if err != nil {
return "", err
}
defer out.Close()
fmt.Printf("Downloading from Lucida: %s\n", fileName)
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
out.Close()
os.Remove(filePath)
return "", fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return filePath, nil
}
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
fmt.Println("Attempting download via Lucida (Priority)...")
filePath, err := a.DownloadFromLucida(amazonURL, outputDir, quality)
if err == nil {
return filePath, nil
}
fmt.Printf("Lucida failed: %v\nTrying Double-Double as fallback...\n", err)
var lastError error var lastError error
lastError = err
for _, region := range a.regions { for _, region := range a.regions {
fmt.Printf("\nTrying region: %s...\n", region) fmt.Printf("\nTrying region: %s...\n", region)
@@ -357,7 +561,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("all regions failed. Last error: %v", lastError) return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -377,7 +581,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
fmt.Printf("Using Amazon URL: %s\n", amazonURL) fmt.Printf("Using Amazon URL: %s\n", amazonURL)
filePath, err := a.DownloadFromService(amazonURL, outputDir) filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -492,12 +696,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
return filePath, nil return filePath, nil
} }
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil { if err != nil {
return "", err return "", err
} }
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
} }
+3 -3
View File
@@ -253,11 +253,11 @@ function App() {
return null; return null;
if ("track" in metadata.metadata) { if ("track" in metadata.metadata) {
const { track } = metadata.metadata; const { track } = metadata.metadata;
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.getAvailability(track.spotify_id || "")} downloadingCover={cover.downloadingCover} downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")} failedCover={cover.failedCovers.has(track.spotify_id || "")} skippedCover={cover.skippedCovers.has(track.spotify_id || "")} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onOpenFolder={handleOpenFolder}/>); return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>);
} }
if ("album_info" in metadata.metadata) { if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata; const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, undefined, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => { return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
@@ -271,7 +271,7 @@ function App() {
} }
if ("playlist_info" in metadata.metadata) { if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata; const { playlist_info, track_list } = metadata.metadata;
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
+24 -27
View File
@@ -170,9 +170,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label> <Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/> <InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music" />
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5"> <Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/> <FolderOpen className="h-4 w-4" />
Browse Browse
</Button> </Button>
</div> </div>
@@ -183,7 +183,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label htmlFor="theme-mode">Mode</Label> <Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}> <Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode"> <SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode"/> <SelectValue placeholder="Select theme mode" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
@@ -198,14 +198,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label htmlFor="theme">Accent</Label> <Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}> <Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme"> <SelectTrigger id="theme">
<SelectValue placeholder="Select a theme"/> <SelectValue placeholder="Select a theme" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}> {themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{ <span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}}/> }} />
{theme.label} {theme.label}
</span> </span>
</SelectItem>))} </SelectItem>))}
@@ -218,7 +218,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label htmlFor="font">Font</Label> <Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}> <Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font"> <SelectTrigger id="font">
<SelectValue placeholder="Select a font"/> <SelectValue placeholder="Select a font" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}> {FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
@@ -231,7 +231,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label> <Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/> <Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))} />
</div> </div>
</div> </div>
@@ -243,7 +243,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}> <Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
<SelectTrigger id="downloader" className="h-9 w-fit"> <SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/> <SelectValue placeholder="Select a source" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
@@ -279,14 +279,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</SelectContent> </SelectContent>
</Select>)} </Select>)}
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}> {tempSettings.downloader === "amazon" && (
<SelectTrigger className="h-9 w-fit"> <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">
<SelectValue /> 16-bit/44.1kHz or 24-bit/48kHz+
</SelectTrigger> </div>
<SelectContent> )}
<SelectItem value="HI_RES">Hi-Res (24-bit/48kHz+)</SelectItem>
</SelectContent>
</Select>)}
</div> </div>
</div> </div>
@@ -294,15 +291,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label> <Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/> <Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))} />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label> <Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/> <Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))} />
</div> </div>
</div> </div>
<div className="border-t"/> <div className="border-t" />
<div className="space-y-2"> <div className="space-y-2">
@@ -310,7 +307,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label className="text-sm">Folder Structure</Label> <Label className="text-sm">Folder Structure</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
@@ -333,14 +330,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)} {tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1" />)}
</div> </div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground"> {tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span> Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</p>)} </p>)}
</div> </div>
<div className="border-t"/> <div className="border-t" />
<div className="space-y-2"> <div className="space-y-2">
@@ -348,7 +345,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label className="text-sm">Filename Format</Label> <Label className="text-sm">Filename Format</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
@@ -371,7 +368,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} {tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1" />)}
</div> </div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground"> {tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span> Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
@@ -383,11 +380,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex gap-2 justify-between pt-4 border-t"> <div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5"> <Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4"/> <RotateCcw className="h-4 w-4" />
Reset to Default Reset to Default
</Button> </Button>
<Button onClick={handleSave} className="gap-1.5"> <Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4"/> <Save className="h-4 w-4" />
Save Changes Save Changes
</Button> </Button>
</div> </div>
@@ -429,7 +426,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</div> </div>
<span>{installProgress}%</span> <span>{installProgress}%</span>
</div> </div>
<Progress value={installProgress} className="h-2"/> <Progress value={installProgress} className="h-2" />
</div>)} </div>)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
+6 -2
View File
@@ -35,7 +35,9 @@ export function useCover() {
track: position, track: position,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -129,7 +131,9 @@ export function useCover() {
track: trackPosition, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
+10 -5
View File
@@ -44,7 +44,7 @@ export function useDownload() {
const shouldStopDownloadRef = useRef(false); const shouldStopDownloadRef = useRef(false);
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader; const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false; let useAlbumTrackNumber = false;
@@ -82,7 +82,9 @@ export function useDownload() {
year: yearValue, year: yearValue,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && !useAlbumSubfolder) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -346,7 +348,8 @@ export function useDownload() {
year: yearValue, year: yearValue,
playlist: folderName?.replace(/\//g, placeholder), playlist: folderName?.replace(/\//g, placeholder),
}; };
if (folderName && !isAlbum) { const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -575,7 +578,8 @@ export function useDownload() {
setDownloadProgress(0); setDownloadProgress(0);
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
if (folderName && !isAlbum) { const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
const selectedTrackObjects = selectedTracks const selectedTrackObjects = selectedTracks
@@ -723,7 +727,8 @@ export function useDownload() {
setDownloadProgress(0); setDownloadProgress(0);
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
if (folderName && !isAlbum) { const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
logger.info(`checking existing files in parallel...`); logger.info(`checking existing files in parallel...`);
+6 -2
View File
@@ -32,7 +32,9 @@ export function useLyrics() {
track: position, track: position,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -125,7 +127,9 @@ export function useLyrics() {
track: trackPosition, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
+5 -5
View File
@@ -22,7 +22,7 @@ export interface Settings {
operatingSystem: "Windows" | "linux/MacOS"; operatingSystem: "Windows" | "linux/MacOS";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7"; qobuzQuality: "6" | "7";
amazonQuality: "HI_RES"; amazonQuality: "original";
} }
export const FOLDER_PRESETS: Record<FolderPreset, { export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
@@ -95,7 +95,7 @@ export const DEFAULT_SETTINGS: Settings = {
operatingSystem: detectOS(), operatingSystem: detectOS(),
tidalQuality: "LOSSLESS", tidalQuality: "LOSSLESS",
qobuzQuality: "6", qobuzQuality: "6",
amazonQuality: "HI_RES" amazonQuality: "original"
}; };
export const FONT_OPTIONS: { export const FONT_OPTIONS: {
value: FontFamily; value: FontFamily;
@@ -119,7 +119,7 @@ export const FONT_OPTIONS: {
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' }, { value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' }, { value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' }, { value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
]; ];
export function applyFont(fontFamily: FontFamily): void { export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily); const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) { if (font) {
@@ -194,7 +194,7 @@ function getSettingsFromLocalStorage(): Settings {
parsed.qobuzQuality = "6"; parsed.qobuzQuality = "6";
} }
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "HI_RES"; parsed.amazonQuality = "original";
} }
return { ...DEFAULT_SETTINGS, ...parsed }; return { ...DEFAULT_SETTINGS, ...parsed };
} }
@@ -264,7 +264,7 @@ export async function loadSettings(): Promise<Settings> {
parsed.qobuzQuality = "6"; parsed.qobuzQuality = "6";
} }
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "HI_RES"; parsed.amazonQuality = "original";
} }
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!; return cachedSettings!;
+1
View File
@@ -45,6 +45,7 @@ export interface AlbumResponse {
track_list: TrackMetadata[]; track_list: TrackMetadata[];
} }
export interface PlaylistInfo { export interface PlaylistInfo {
name: string;
tracks: { tracks: {
total: number; total: number;
}; };