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);
+174 -177
View File
@@ -27,140 +27,140 @@ const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>); </svg>);
interface SettingsPageProps { interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void; onResetRequest?: (resetFn: () => void) => void;
} }
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) { export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings()); const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings); const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showFFmpegWarning, setShowFFmpegWarning] = useState(false); const [showFFmpegWarning, setShowFFmpegWarning] = useState(false);
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
const [installProgress, setInstallProgress] = useState(0); const [installProgress, setInstallProgress] = useState(0);
const downloadProgress = useDownloadProgress(); const downloadProgress = useDownloadProgress();
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => { const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings(); const freshSavedSettings = getSettings();
flushSync(() => { flushSync(() => {
setTempSettings(freshSavedSettings); setTempSettings(freshSavedSettings);
setIsDark(document.documentElement.classList.contains('dark')); setIsDark(document.documentElement.classList.contains('dark'));
}); });
}, []); }, []);
useEffect(() => { useEffect(() => {
if (onResetRequest) { if (onResetRequest) {
onResetRequest(resetToSaved); onResetRequest(resetToSaved);
} }
}, [onResetRequest, resetToSaved]); }, [onResetRequest, resetToSaved]);
useEffect(() => { useEffect(() => {
onUnsavedChangesChange?.(hasUnsavedChanges); onUnsavedChangesChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChangesChange]); }, [hasUnsavedChanges, onUnsavedChangesChange]);
useEffect(() => { useEffect(() => {
applyThemeMode(savedSettings.themeMode); applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme); applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); }
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [savedSettings.themeMode, savedSettings.theme]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
await saveSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = async () => {
await saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
}; };
const handleReset = async () => { mediaQuery.addEventListener("change", handleChange);
const defaultSettings = await resetToDefaultSettings(); return () => mediaQuery.removeEventListener("change", handleChange);
setTempSettings(defaultSettings); }, [savedSettings.themeMode, savedSettings.theme]);
setSavedSettings(defaultSettings); useEffect(() => {
applyThemeMode(defaultSettings.themeMode); applyThemeMode(tempSettings.themeMode);
applyTheme(defaultSettings.theme); applyTheme(tempSettings.theme);
applyFont(defaultSettings.fontFamily); applyFont(tempSettings.fontFamily);
setShowResetConfirm(false); setTimeout(() => {
toast.success("Settings reset to default"); setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
await saveSettings(settingsWithDefaults);
}
}; };
const handleBrowseFolder = async () => { loadDefaults();
try { }, []);
const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); const handleSave = async () => {
if (selectedPath && selectedPath.trim() !== "") { await saveSettings(tempSettings);
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); setSavedSettings(tempSettings);
} toast.success("Settings saved");
onUnsavedChangesChange?.(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
}
catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
if (value === "HI_RES_LOSSLESS") {
try {
const { CheckFFmpegInstalled } = await import("../../wailsjs/go/main/App");
const isInstalled = await CheckFFmpegInstalled();
if (!isInstalled) {
setShowFFmpegWarning(true);
return;
} }
catch (error) { }
console.error("Error selecting folder:", error); catch (error) {
toast.error(`Error selecting folder: ${error}`); console.error("Error checking FFmpeg:", error);
} }
}; }
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
if (value === "HI_RES_LOSSLESS") { };
try { const handleInstallFFmpeg = async () => {
const { CheckFFmpegInstalled } = await import("../../wailsjs/go/main/App"); setIsInstallingFFmpeg(true);
const isInstalled = await CheckFFmpegInstalled(); setInstallProgress(0);
if (!isInstalled) { try {
setShowFFmpegWarning(true); const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App");
return; const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime");
} EventsOn("ffmpeg:progress", (progress: number) => {
} setInstallProgress(progress);
catch (error) { });
console.error("Error checking FFmpeg:", error); const response = await DownloadFFmpeg();
} EventsOff("ffmpeg:progress");
} if (response.success) {
setTempSettings((prev) => ({ ...prev, tidalQuality: value })); toast.success("FFmpeg installed successfully!");
}; setShowFFmpegWarning(false);
const handleInstallFFmpeg = async () => { setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" }));
setIsInstallingFFmpeg(true); }
setInstallProgress(0); else {
try { toast.error(`Failed to install FFmpeg: ${response.error}`);
const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App"); }
const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime"); }
EventsOn("ffmpeg:progress", (progress: number) => { catch (error) {
setInstallProgress(progress); console.error("Error installing FFmpeg:", error);
}); toast.error(`Error during FFmpeg installation: ${error}`);
const response = await DownloadFFmpeg(); }
EventsOff("ffmpeg:progress"); finally {
if (response.success) { setIsInstallingFFmpeg(false);
toast.success("FFmpeg installed successfully!"); setInstallProgress(0);
setShowFFmpegWarning(false); }
setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" })); };
} return (<div className="space-y-6">
else {
toast.error(`Failed to install FFmpeg: ${response.error}`);
}
}
catch (error) {
console.error("Error installing FFmpeg:", error);
toast.error(`Error during FFmpeg installation: ${error}`);
}
finally {
setIsInstallingFFmpeg(false);
setInstallProgress(0);
}
};
return (<div className="space-y-6">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -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>
@@ -319,13 +316,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => { <Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value]; const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({ setTempSettings(prev => ({
...prev, ...prev,
folderPreset: value, folderPreset: value,
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
})); }));
}}> }}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -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>
@@ -357,13 +354,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => { <Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value]; const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({ setTempSettings(prev => ({
...prev, ...prev,
filenamePreset: value, filenamePreset: value,
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
})); }));
}}> }}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -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>
@@ -419,24 +416,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</div> </div>
{isInstallingFFmpeg && (<div className="space-y-2 py-2"> {isInstallingFFmpeg && (<div className="space-y-2 py-2">
<div className="flex justify-between text-xs font-medium"> <div className="flex justify-between text-xs font-medium">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span>Downloading & Extracting...</span> <span>Downloading & Extracting...</span>
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-muted-foreground font-normal"> {downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-muted-foreground font-normal">
{downloadProgress.mb_downloaded.toFixed(2)} MB {downloadProgress.mb_downloaded.toFixed(2)} MB
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`} {downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`}
</span>)} </span>)}
</div>
<span>{installProgress}%</span>
</div> </div>
<Progress value={installProgress} className="h-2"/> <span>{installProgress}%</span>
</div>)} </div>
<Progress value={installProgress} className="h-2" />
</div>)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!isInstallingFFmpeg && (<DialogFooter> {!isInstallingFFmpeg && (<DialogFooter>
<Button variant="outline" onClick={() => setShowFFmpegWarning(false)}>Cancel</Button> <Button variant="outline" onClick={() => setShowFFmpegWarning(false)}>Cancel</Button>
<Button onClick={handleInstallFFmpeg}>Install FFmpeg</Button> <Button onClick={handleInstallFFmpeg}>Install FFmpeg</Button>
</DialogFooter>)} </DialogFooter>)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>); </div>);
+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) {
+22 -22
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,31 +95,31 @@ 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;
label: string; label: string;
fontFamily: string; fontFamily: string;
}[] = [ }[] = [
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' }, { value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' }, { value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' }, { value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' }, { value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' }, { value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' }, { value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' }, { value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' }, { value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' }, { value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' }, { value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' }, { value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' }, { value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' }, { value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' }, { value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ 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;
}; };