Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f135f1153 | |||
| 4ee252f438 | |||
| 2fc08de757 | |||
| 6e3ca48d3f | |||
| 46a7777698 | |||
| 0f2174bf80 | |||
| 36fb34dc63 | |||
| 7f859db173 | |||
| 6e66105481 |
@@ -1,6 +1,6 @@
|
|||||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||

|
<!--  -->
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"spotiflac/backend"
|
"spotiflac/backend"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var isrcRegex = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$`)
|
||||||
|
|
||||||
|
func isValidISRC(isrc string) bool {
|
||||||
|
return isrcRegex.MatchString(isrc)
|
||||||
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
@@ -283,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{
|
||||||
@@ -291,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":
|
||||||
@@ -336,6 +345,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deezerISRC := req.ISRC
|
deezerISRC := req.ISRC
|
||||||
|
|
||||||
|
if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) {
|
||||||
|
deezerISRC = ""
|
||||||
|
}
|
||||||
|
|
||||||
if deezerISRC == "" && req.SpotifyID != "" {
|
if deezerISRC == "" && req.SpotifyID != "" {
|
||||||
|
|
||||||
songlinkClient := backend.NewSongLinkClient()
|
songlinkClient := backend.NewSongLinkClient()
|
||||||
@@ -370,6 +384,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err))
|
||||||
|
|
||||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||||
|
|
||||||
@@ -828,16 +843,19 @@ type DownloadFFmpegResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "starting")
|
||||||
err := backend.DownloadFFmpeg(func(progress int) {
|
err := backend.DownloadFFmpeg(func(progress int) {
|
||||||
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress)
|
runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed")
|
||||||
return DownloadFFmpegResponse{
|
return DownloadFFmpegResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed")
|
||||||
return DownloadFFmpegResponse{
|
return DownloadFFmpegResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "FFmpeg installed successfully",
|
Message: "FFmpeg installed successfully",
|
||||||
@@ -1049,3 +1067,63 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
|||||||
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
||||||
backend.SkipDownloadItem(itemID, filePath)
|
backend.SkipDownloadItem(itemID, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) GetPreviewURL(trackID string) (string, error) {
|
||||||
|
return backend.GetPreviewURL(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetConfigPath() (string, error) {
|
||||||
|
dir, err := backend.GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "config.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveSettings(settings map[string]interface{}) error {
|
||||||
|
configPath, err := a.GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoadSettings() (map[string]interface{}, error) {
|
||||||
|
configPath, err := a.GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CheckFFmpegInstalled() (bool, error) {
|
||||||
|
return backend.IsFFmpegInstalled()
|
||||||
|
}
|
||||||
|
|||||||
+209
-5
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -12,6 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
spotifySize300 = "ab67616d00001e02"
|
||||||
spotifySize640 = "ab67616d0000b273"
|
spotifySize640 = "ab67616d0000b273"
|
||||||
spotifySizeMax = "ab67616d000082c1"
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
)
|
)
|
||||||
@@ -118,21 +119,30 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
|||||||
return filename + ".cover.jpg"
|
return filename + ".cover.jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize640) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
}
|
}
|
||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||||
|
|
||||||
|
mediumURL := convertSmallToMedium(imageURL)
|
||||||
|
if strings.Contains(mediumURL, spotifySize640) {
|
||||||
|
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
|
||||||
|
}
|
||||||
|
return mediumURL
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return fmt.Errorf("cover URL is required")
|
return fmt.Errorf("cover URL is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := coverURL
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
if embedMaxQualityCover {
|
if embedMaxQualityCover {
|
||||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
downloadURL = c.getMaxResolutionURL(downloadURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Get(downloadURL)
|
resp, err := c.httpClient.Get(downloadURL)
|
||||||
|
|||||||
@@ -403,6 +403,25 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
outputDir = NormalizePath(outputDir)
|
outputDir = NormalizePath(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safeArtist := sanitizeFilename(req.AlbumArtist)
|
||||||
|
if safeArtist == "" {
|
||||||
|
safeArtist = sanitizeFilename(req.ArtistName)
|
||||||
|
}
|
||||||
|
safeAlbum := sanitizeFilename(req.AlbumName)
|
||||||
|
|
||||||
|
if safeArtist != "" && safeAlbum != "" {
|
||||||
|
artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum)
|
||||||
|
if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() {
|
||||||
|
outputDir = artistAlbumPath
|
||||||
|
} else {
|
||||||
|
|
||||||
|
artistPath := filepath.Join(outputDir, safeArtist)
|
||||||
|
if info, err := os.Stat(artistPath); err == nil && info.IsDir() {
|
||||||
|
outputDir = artistPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
+33
-10
@@ -127,12 +127,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||||
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
|
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit\n")
|
||||||
|
|
||||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||||
|
|
||||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||||
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
|
fmt.Printf("Trying Primary API: %s\n", primaryURL)
|
||||||
|
|
||||||
resp, err := q.client.Get(primaryURL)
|
resp, err := q.client.Get(primaryURL)
|
||||||
if err == nil && resp.StatusCode == 200 {
|
if err == nil && resp.StatusCode == 200 {
|
||||||
@@ -143,7 +143,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
var streamResp QobuzStreamResponse
|
||||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||||
fmt.Printf("Got download URL from primary API\n")
|
fmt.Printf("✓ Got download URL from Primary API\n")
|
||||||
return streamResp.URL, nil
|
return streamResp.URL, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,20 +151,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Primary API failed, trying fallback...")
|
fmt.Println("Primary API failed, trying Fallback API #1...")
|
||||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||||
|
|
||||||
resp, err = q.client.Get(fallbackURL)
|
resp, err = q.client.Get(fallbackURL)
|
||||||
|
if err == nil && resp.StatusCode == 200 {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err == nil && len(body) > 0 {
|
||||||
|
fmt.Printf("Fallback API #1 response: %s\n", string(body))
|
||||||
|
|
||||||
|
var streamResp QobuzStreamResponse
|
||||||
|
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||||
|
fmt.Printf("✓ Got download URL from Fallback API #1\n")
|
||||||
|
return streamResp.URL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Fallback API #1 failed, trying Fallback API #2...")
|
||||||
|
fallback2Base, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9xb2J1ei5zcXVpZC53dGYvYXBpL2Rvd25sb2FkLW11c2ljP3RyYWNrX2lkPQ==")
|
||||||
|
fallback2URL := fmt.Sprintf("%s%d&quality=%s", string(fallback2Base), trackID, qualityCode)
|
||||||
|
|
||||||
|
resp, err = q.client.Get(fallback2URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", fmt.Errorf("all APIs failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
fmt.Printf("Fallback API error response: %s\n", string(body))
|
fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body))
|
||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
return "", fmt.Errorf("all APIs returned non-200 status")
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
@@ -176,7 +199,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("API returned empty response")
|
return "", fmt.Errorf("API returned empty response")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Fallback API response: %s\n", string(body))
|
fmt.Printf("Fallback API #2 response: %s\n", string(body))
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
var streamResp QobuzStreamResponse
|
||||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||||
@@ -189,10 +212,10 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if streamResp.URL == "" {
|
if streamResp.URL == "" {
|
||||||
return "", fmt.Errorf("no download URL available")
|
return "", fmt.Errorf("no download URL available from any API")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Got download URL from fallback API\n")
|
fmt.Printf("✓ Got download URL from Fallback API #2\n")
|
||||||
return streamResp.URL, nil
|
return streamResp.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
|
||||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
|
||||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
|
||||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
|
||||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
|
||||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
|
||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
|
||||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
|
||||||
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
|
||||||
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
|
||||||
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
|
||||||
'っ': "",
|
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
|
||||||
}
|
|
||||||
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
|
||||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
|
||||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
|
||||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
|
||||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
|
||||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
|
||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
|
||||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
|
||||||
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
|
||||||
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
|
||||||
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
|
||||||
'ッ': "",
|
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
|
||||||
|
|
||||||
'ー': "",
|
|
||||||
'ヴ': "vu",
|
|
||||||
}
|
|
||||||
|
|
||||||
var combinationHiragana = map[string]string{
|
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
|
||||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
|
||||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
|
||||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
|
||||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
|
||||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
|
||||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
|
||||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
|
||||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
|
||||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
|
||||||
}
|
|
||||||
|
|
||||||
var combinationKatakana = map[string]string{
|
|
||||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
|
||||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
|
||||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
|
||||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
|
||||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
|
||||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
|
||||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
|
||||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
|
||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
|
||||||
|
|
||||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
|
||||||
}
|
|
||||||
|
|
||||||
func ContainsJapanese(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHiragana(r rune) bool {
|
|
||||||
return r >= 0x3040 && r <= 0x309F
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKatakana(r rune) bool {
|
|
||||||
return r >= 0x30A0 && r <= 0x30FF
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
|
||||||
return (r >= 0x4E00 && r <= 0x9FFF) ||
|
|
||||||
(r >= 0x3400 && r <= 0x4DBF)
|
|
||||||
}
|
|
||||||
|
|
||||||
func JapaneseToRomaji(text string) string {
|
|
||||||
if !ContainsJapanese(text) {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
runes := []rune(text)
|
|
||||||
i := 0
|
|
||||||
|
|
||||||
for i < len(runes) {
|
|
||||||
|
|
||||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
|
||||||
nextRomaji := ""
|
|
||||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
}
|
|
||||||
if len(nextRomaji) > 0 {
|
|
||||||
result.WriteByte(nextRomaji[0])
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if i < len(runes)-1 {
|
|
||||||
combo := string(runes[i : i+2])
|
|
||||||
if romaji, ok := combinationHiragana[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if romaji, ok := combinationKatakana[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r := runes[i]
|
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
} else if isKanji(r) {
|
|
||||||
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else {
|
|
||||||
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
|
||||||
|
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
|
||||||
artistRomaji := JapaneseToRomaji(artistName)
|
|
||||||
|
|
||||||
trackClean := cleanSearchQuery(trackRomaji)
|
|
||||||
artistClean := cleanSearchQuery(artistRomaji)
|
|
||||||
|
|
||||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanSearchQuery(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else if r == '-' || r == '\'' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(result.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanToASCII(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
|
||||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else if r == ',' || r == '.' {
|
|
||||||
|
|
||||||
result.WriteRune(' ')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
|
||||||
return strings.TrimSpace(cleaned)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+538
-83
@@ -2,23 +2,18 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
apiBaseURL = "https://afkarxyz.web.id"
|
|
||||||
apiKey = "NDAwNDAxNDAzNDA0NTAwNTAyNTAz"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
)
|
)
|
||||||
@@ -51,6 +46,7 @@ type TrackMetadata struct {
|
|||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
Publisher string `json:"publisher,omitempty"`
|
Publisher string `json:"publisher,omitempty"`
|
||||||
Plays string `json:"plays,omitempty"`
|
Plays string `json:"plays,omitempty"`
|
||||||
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistSimple struct {
|
type ArtistSimple struct {
|
||||||
@@ -82,6 +78,7 @@ type AlbumTrackMetadata struct {
|
|||||||
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
|
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
|
||||||
Plays string `json:"plays,omitempty"`
|
Plays string `json:"plays,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
@@ -186,7 +183,6 @@ type apiTrackResponse struct {
|
|||||||
Disc int `json:"disc"`
|
Disc int `json:"disc"`
|
||||||
Discs int `json:"discs"`
|
Discs int `json:"discs"`
|
||||||
Copyright string `json:"copyright"`
|
Copyright string `json:"copyright"`
|
||||||
Label string `json:"label"`
|
|
||||||
Plays string `json:"plays"`
|
Plays string `json:"plays"`
|
||||||
Album struct {
|
Album struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -233,16 +229,17 @@ type apiPlaylistResponse struct {
|
|||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Followers int `json:"followers"`
|
Followers int `json:"followers"`
|
||||||
Tracks []struct {
|
Tracks []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
ArtistIds []string `json:"artistIds"`
|
ArtistIds []string `json:"artistIds"`
|
||||||
Plays string `json:"plays"`
|
Plays string `json:"plays"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Album string `json:"album"`
|
Album string `json:"album"`
|
||||||
AlbumID string `json:"albumId"`
|
AlbumArtist string `json:"albumArtist"`
|
||||||
Duration string `json:"duration"`
|
AlbumID string `json:"albumId"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,39 +383,429 @@ func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw inte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) (*apiTrackResponse, error) {
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) (*apiTrackResponse, error) {
|
||||||
url := fmt.Sprintf("%s/track/%s", apiBaseURL, trackID)
|
client := NewSpotifyClient()
|
||||||
var data apiTrackResponse
|
if err := client.Initialize(); err != nil {
|
||||||
if err := c.getJSON(ctx, url, &data); err != nil {
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return &data, nil
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"uri": fmt.Sprintf("spotify:track:%s", trackID),
|
||||||
|
},
|
||||||
|
"operationName": "getTrack",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.Query(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query track: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumFetchData map[string]interface{}
|
||||||
|
if trackData, ok := data["data"].(map[string]interface{}); ok {
|
||||||
|
if trackUnion, ok := trackData["trackUnion"].(map[string]interface{}); ok {
|
||||||
|
if albumOfTrack, ok := trackUnion["albumOfTrack"].(map[string]interface{}); ok {
|
||||||
|
albumID := ""
|
||||||
|
if id, ok := albumOfTrack["id"].(string); ok && id != "" {
|
||||||
|
albumID = id
|
||||||
|
} else if uri, ok := albumOfTrack["uri"].(string); ok && uri != "" {
|
||||||
|
if strings.Contains(uri, ":") {
|
||||||
|
parts := strings.Split(uri, ":")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
albumID = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if albumID != "" {
|
||||||
|
albumPayload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"uri": fmt.Sprintf("spotify:album:%s", albumID),
|
||||||
|
"locale": "",
|
||||||
|
"offset": 0,
|
||||||
|
"limit": 1,
|
||||||
|
},
|
||||||
|
"operationName": "getAlbum",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
albumFetchData, _ = client.Query(albumPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredData := FilterTrack(data, albumFetchData)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(filteredData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal filtered data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result apiTrackResponse
|
||||||
|
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal to apiTrackResponse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) {
|
||||||
url := fmt.Sprintf("%s/album/%s", apiBaseURL, albumID)
|
client := NewSpotifyClient()
|
||||||
var data apiAlbumResponse
|
if err := client.Initialize(); err != nil {
|
||||||
if err := c.getJSON(ctx, url, &data); err != nil {
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return &data, nil
|
|
||||||
|
allItems := []interface{}{}
|
||||||
|
offset := 0
|
||||||
|
limit := 1000
|
||||||
|
var totalCount interface{}
|
||||||
|
var data map[string]interface{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"uri": fmt.Sprintf("spotify:album:%s", albumID),
|
||||||
|
"locale": "",
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
"operationName": "getAlbum",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.Query(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
data = response
|
||||||
|
}
|
||||||
|
|
||||||
|
albumData := getMap(getMap(response, "data"), "albumUnion")
|
||||||
|
tracksData := getMap(albumData, "tracksV2")
|
||||||
|
items := getSlice(tracksData, "items")
|
||||||
|
|
||||||
|
if items == nil || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allItems = append(allItems, items...)
|
||||||
|
|
||||||
|
if totalCount == nil {
|
||||||
|
if tc, ok := tracksData["totalCount"].(float64); ok {
|
||||||
|
totalCount = int(tc)
|
||||||
|
} else {
|
||||||
|
totalCount = len(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tcInt := 0
|
||||||
|
if tc, ok := totalCount.(int); ok {
|
||||||
|
tcInt = tc
|
||||||
|
} else if tc, ok := totalCount.(float64); ok {
|
||||||
|
tcInt = int(tc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allItems) >= tcInt || len(items) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if data != nil && len(allItems) > 0 {
|
||||||
|
dataMap := getMap(data, "data")
|
||||||
|
albumUnion := getMap(dataMap, "albumUnion")
|
||||||
|
tracksV2 := getMap(albumUnion, "tracksV2")
|
||||||
|
tracksV2["items"] = allItems
|
||||||
|
tracksV2["totalCount"] = len(allItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredData := FilterAlbum(data)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(filteredData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal filtered data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result apiAlbumResponse
|
||||||
|
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal to apiAlbumResponse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) {
|
||||||
url := fmt.Sprintf("%s/playlist/%s", apiBaseURL, playlistID)
|
client := NewSpotifyClient()
|
||||||
var data apiPlaylistResponse
|
if err := client.Initialize(); err != nil {
|
||||||
if err := c.getJSON(ctx, url, &data); err != nil {
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return &data, nil
|
|
||||||
|
allItems := []interface{}{}
|
||||||
|
offset := 0
|
||||||
|
limit := 1000
|
||||||
|
var totalCount interface{}
|
||||||
|
var data map[string]interface{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"uri": fmt.Sprintf("spotify:playlist:%s", playlistID),
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"enableWatchFeedEntrypoint": false,
|
||||||
|
},
|
||||||
|
"operationName": "fetchPlaylist",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.Query(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query playlist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
data = response
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistData := getMap(getMap(response, "data"), "playlistV2")
|
||||||
|
content := getMap(playlistData, "content")
|
||||||
|
items := getSlice(content, "items")
|
||||||
|
|
||||||
|
if items == nil || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allItems = append(allItems, items...)
|
||||||
|
|
||||||
|
if totalCount == nil {
|
||||||
|
if tc, ok := content["totalCount"].(float64); ok {
|
||||||
|
totalCount = int(tc)
|
||||||
|
} else {
|
||||||
|
totalCount = len(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tcInt := 0
|
||||||
|
if tc, ok := totalCount.(int); ok {
|
||||||
|
tcInt = tc
|
||||||
|
} else if tc, ok := totalCount.(float64); ok {
|
||||||
|
tcInt = int(tc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allItems) >= tcInt || len(items) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if data != nil && len(allItems) > 0 {
|
||||||
|
dataMap := getMap(data, "data")
|
||||||
|
playlistV2 := getMap(dataMap, "playlistV2")
|
||||||
|
content := getMap(playlistV2, "content")
|
||||||
|
content["items"] = allItems
|
||||||
|
content["totalCount"] = len(allItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredData := FilterPlaylist(data)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(filteredData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal filtered data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result apiPlaylistResponse
|
||||||
|
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal to apiPlaylistResponse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) {
|
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) {
|
||||||
url := fmt.Sprintf("%s/artist/%s", apiBaseURL, parsed.ID)
|
client := NewSpotifyClient()
|
||||||
var data apiArtistResponse
|
if err := client.Initialize(); err != nil {
|
||||||
if err := c.getJSON(ctx, url, &data); err != nil {
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return &data, nil
|
|
||||||
|
overviewPayload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"uri": fmt.Sprintf("spotify:artist:%s", parsed.ID),
|
||||||
|
"locale": "",
|
||||||
|
},
|
||||||
|
"operationName": "queryArtistOverview",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "446130b4a0aa6522a686aafccddb0ae849165b5e0436fd802f96e0243617b5d8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.Query(overviewPayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query artist overview: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allDiscographyItems := []interface{}{}
|
||||||
|
offset := 0
|
||||||
|
limit := 50
|
||||||
|
var totalCount interface{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
discographyPayload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"uri": fmt.Sprintf("spotify:artist:%s", parsed.ID),
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"order": "DATE_DESC",
|
||||||
|
},
|
||||||
|
"operationName": "queryArtistDiscographyAll",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "5e07d323febb57b4a56a42abbf781490e58764aa45feb6e3dc0591564fc56599",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.Query(discographyPayload)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
discographyData := getMap(getMap(getMap(response, "data"), "artistUnion"), "discography")
|
||||||
|
allData := getMap(discographyData, "all")
|
||||||
|
items := getSlice(allData, "items")
|
||||||
|
|
||||||
|
if items == nil || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allDiscographyItems = append(allDiscographyItems, items...)
|
||||||
|
|
||||||
|
if totalCount == nil {
|
||||||
|
if tc, ok := allData["totalCount"].(float64); ok {
|
||||||
|
totalCount = int(tc)
|
||||||
|
} else {
|
||||||
|
totalCount = len(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tcInt := 0
|
||||||
|
if tc, ok := totalCount.(int); ok {
|
||||||
|
tcInt = tc
|
||||||
|
} else if tc, ok := totalCount.(float64); ok {
|
||||||
|
tcInt = int(tc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allDiscographyItems) >= tcInt || len(items) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += limit
|
||||||
|
}
|
||||||
|
|
||||||
|
albumsItems := []interface{}{}
|
||||||
|
compilationsItems := []interface{}{}
|
||||||
|
singlesItems := []interface{}{}
|
||||||
|
|
||||||
|
for _, item := range allDiscographyItems {
|
||||||
|
itemMap, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
releases := getMap(itemMap, "releases")
|
||||||
|
releaseItems := getSlice(releases, "items")
|
||||||
|
var release map[string]interface{}
|
||||||
|
if len(releaseItems) > 0 {
|
||||||
|
if r, ok := releaseItems[0].(map[string]interface{}); ok {
|
||||||
|
release = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if release != nil {
|
||||||
|
releaseType := getString(release, "type")
|
||||||
|
switch releaseType {
|
||||||
|
case "ALBUM":
|
||||||
|
albumsItems = append(albumsItems, item)
|
||||||
|
case "COMPILATION":
|
||||||
|
compilationsItems = append(compilationsItems, item)
|
||||||
|
case "SINGLE":
|
||||||
|
singlesItems = append(singlesItems, item)
|
||||||
|
default:
|
||||||
|
singlesItems = append(singlesItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allDiscographyItems) > 0 {
|
||||||
|
dataMap := getMap(data, "data")
|
||||||
|
artistUnion := getMap(dataMap, "artistUnion")
|
||||||
|
discographyMap := getMap(artistUnion, "discography")
|
||||||
|
|
||||||
|
if len(albumsItems) > 0 {
|
||||||
|
discographyMap["albums"] = map[string]interface{}{
|
||||||
|
"items": albumsItems,
|
||||||
|
"totalCount": len(albumsItems),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(compilationsItems) > 0 {
|
||||||
|
discographyMap["compilations"] = map[string]interface{}{
|
||||||
|
"items": compilationsItems,
|
||||||
|
"totalCount": len(compilationsItems),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(singlesItems) > 0 {
|
||||||
|
discographyMap["singles"] = map[string]interface{}{
|
||||||
|
"items": singlesItems,
|
||||||
|
"totalCount": len(singlesItems),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discographyMap["all"] = map[string]interface{}{
|
||||||
|
"items": allDiscographyItems,
|
||||||
|
"totalCount": len(allDiscographyItems),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredData := FilterArtist(data)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(filteredData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal filtered data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result apiArtistResponse
|
||||||
|
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal to apiArtistResponse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResponse {
|
func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResponse {
|
||||||
@@ -426,12 +813,12 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
|
|
||||||
externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID)
|
externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID)
|
||||||
|
|
||||||
coverURL := raw.Cover.Medium
|
coverURL := raw.Cover.Small
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
coverURL = raw.Cover.Large
|
coverURL = raw.Cover.Medium
|
||||||
}
|
}
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
coverURL = raw.Cover.Small
|
coverURL = raw.Cover.Large
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseDate := raw.Album.Released
|
releaseDate := raw.Album.Released
|
||||||
@@ -560,7 +947,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
Artists: item.Artist,
|
Artists: item.Artist,
|
||||||
Name: item.Title,
|
Name: item.Title,
|
||||||
AlbumName: item.Album,
|
AlbumName: item.Album,
|
||||||
AlbumArtist: item.Artist,
|
AlbumArtist: item.AlbumArtist,
|
||||||
DurationMS: durationMS,
|
DurationMS: durationMS,
|
||||||
Images: item.Cover,
|
Images: item.Cover,
|
||||||
ReleaseDate: "",
|
ReleaseDate: "",
|
||||||
@@ -690,39 +1077,6 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
decodedKey, err := base64.StdEncoding.DecodeString(apiKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decode API key: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
|
||||||
req.Header.Set("X-API-Key", string(decodedKey))
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("API returned status %d for %s: %s", resp.StatusCode, endpoint, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Unmarshal(body, dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDuration(durationStr string) int {
|
func parseDuration(durationStr string) int {
|
||||||
if durationStr == "" {
|
if durationStr == "" {
|
||||||
return 0
|
return 0
|
||||||
@@ -821,7 +1175,6 @@ func cleanPathParts(path string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseArtistIDsFromString(artists string) []string {
|
func parseArtistIDsFromString(artists string) []string {
|
||||||
|
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,12 +1187,46 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
|||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedQuery := url.QueryEscape(query)
|
client := NewSpotifyClient()
|
||||||
searchURL := fmt.Sprintf("%s/search?q=%s&limit=%d&offset=0", apiBaseURL, encodedQuery, limit)
|
if err := client.Initialize(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"searchTerm": query,
|
||||||
|
"offset": 0,
|
||||||
|
"limit": limit,
|
||||||
|
"numberOfTopResults": 5,
|
||||||
|
"includeAudiobooks": true,
|
||||||
|
"includeArtistHasConcertsField": false,
|
||||||
|
"includePreReleases": true,
|
||||||
|
"includeAuthors": false,
|
||||||
|
},
|
||||||
|
"operationName": "searchDesktop",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "fcad5a3e0d5af727fb76966f06971c19cfa2275e6ff7671196753e008611873c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.Query(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredData := FilterSearch(data)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(filteredData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal filtered data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var apiResp apiSearchResponse
|
var apiResp apiSearchResponse
|
||||||
if err := c.getJSON(ctx, searchURL, &apiResp); err != nil {
|
if err := json.Unmarshal(jsonData, &apiResp); err != nil {
|
||||||
return nil, fmt.Errorf("search failed: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal to apiSearchResponse: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &SearchResponse{
|
response := &SearchResponse{
|
||||||
@@ -916,12 +1303,46 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedQuery := url.QueryEscape(query)
|
client := NewSpotifyClient()
|
||||||
searchURL := fmt.Sprintf("%s/search?q=%s&limit=%d&offset=%d", apiBaseURL, encodedQuery, limit, offset)
|
if err := client.Initialize(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"variables": map[string]interface{}{
|
||||||
|
"searchTerm": query,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"numberOfTopResults": 5,
|
||||||
|
"includeAudiobooks": true,
|
||||||
|
"includeArtistHasConcertsField": false,
|
||||||
|
"includePreReleases": true,
|
||||||
|
"includeAuthors": false,
|
||||||
|
},
|
||||||
|
"operationName": "searchDesktop",
|
||||||
|
"extensions": map[string]interface{}{
|
||||||
|
"persistedQuery": map[string]interface{}{
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "fcad5a3e0d5af727fb76966f06971c19cfa2275e6ff7671196753e008611873c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.Query(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredData := FilterSearch(data)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(filteredData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal filtered data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var apiResp apiSearchResponse
|
var apiResp apiSearchResponse
|
||||||
if err := c.getJSON(ctx, searchURL, &apiResp); err != nil {
|
if err := json.Unmarshal(jsonData, &apiResp); err != nil {
|
||||||
return nil, fmt.Errorf("search failed: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal to apiSearchResponse: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
results := make([]SearchResult, 0)
|
results := make([]SearchResult, 0)
|
||||||
@@ -984,3 +1405,37 @@ func SearchSpotifyByType(ctx context.Context, query string, searchType string, l
|
|||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
return client.SearchByType(ctx, query, searchType, limit, offset)
|
return client.SearchByType(ctx, query, searchType, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPreviewURL(trackID string) (string, error) {
|
||||||
|
if trackID == "" {
|
||||||
|
return "", errors.New("track ID cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
embedURL := fmt.Sprintf("https://open.spotify.com/embed/track/%s", trackID)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get(embedURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch embed page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("embed page returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
html := string(body)
|
||||||
|
re := regexp.MustCompile(`https://p\.scdn\.co/mp3-preview/[a-zA-Z0-9]+`)
|
||||||
|
match := re.FindString(html)
|
||||||
|
|
||||||
|
if match == "" {
|
||||||
|
return "", errors.New("preview URL not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return match, nil
|
||||||
|
}
|
||||||
|
|||||||
+102
-225
@@ -25,13 +25,6 @@ type TidalDownloader struct {
|
|||||||
apiURL string
|
apiURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TidalSearchResponse struct {
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
TotalNumberOfItems int `json:"totalNumberOfItems"`
|
|
||||||
Items []TidalTrack `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TidalTrack struct {
|
type TidalTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -181,184 +174,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
return result.AccessToken, nil
|
return result.AccessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) {
|
|
||||||
return t.SearchTracksWithLimit(query, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*TidalSearchResponse, error) {
|
|
||||||
token, err := t.GetAccessToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=%d&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query), limit)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result TidalSearchResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string, expectedDuration int) (*TidalTrack, error) {
|
|
||||||
|
|
||||||
queries := []string{}
|
|
||||||
|
|
||||||
if artistName != "" && trackName != "" {
|
|
||||||
queries = append(queries, artistName+" "+trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackName != "" {
|
|
||||||
queries = append(queries, trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
|
||||||
|
|
||||||
romajiTrack := JapaneseToRomaji(trackName)
|
|
||||||
romajiArtist := JapaneseToRomaji(artistName)
|
|
||||||
|
|
||||||
cleanRomajiTrack := cleanToASCII(romajiTrack)
|
|
||||||
cleanRomajiArtist := cleanToASCII(romajiArtist)
|
|
||||||
|
|
||||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
|
||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
|
||||||
if !containsQuery(queries, romajiQuery) {
|
|
||||||
queries = append(queries, romajiQuery)
|
|
||||||
fmt.Printf("Japanese detected, adding romaji query: %s\n", romajiQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
|
||||||
if !containsQuery(queries, cleanRomajiTrack) {
|
|
||||||
queries = append(queries, cleanRomajiTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if artistName != "" && cleanRomajiTrack != "" {
|
|
||||||
partialQuery := artistName + " " + cleanRomajiTrack
|
|
||||||
if !containsQuery(queries, partialQuery) {
|
|
||||||
queries = append(queries, partialQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if artistName != "" {
|
|
||||||
artistOnly := cleanToASCII(JapaneseToRomaji(artistName))
|
|
||||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
|
||||||
queries = append(queries, artistOnly)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allTracks []TidalTrack
|
|
||||||
searchedQueries := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, query := range queries {
|
|
||||||
cleanQuery := strings.TrimSpace(query)
|
|
||||||
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
searchedQueries[cleanQuery] = true
|
|
||||||
|
|
||||||
fmt.Printf("Searching Tidal for: %s\n", cleanQuery)
|
|
||||||
|
|
||||||
result, err := t.SearchTracksWithLimit(cleanQuery, 100)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Search error for '%s': %v\n", cleanQuery, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Items) > 0 {
|
|
||||||
fmt.Printf("Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
|
||||||
allTracks = append(allTracks, result.Items...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allTracks) == 0 {
|
|
||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestMatch *TidalTrack
|
|
||||||
if expectedDuration > 0 {
|
|
||||||
tolerance := 3
|
|
||||||
var durationMatches []*TidalTrack
|
|
||||||
|
|
||||||
for i := range allTracks {
|
|
||||||
track := &allTracks[i]
|
|
||||||
durationDiff := track.Duration - expectedDuration
|
|
||||||
if durationDiff < 0 {
|
|
||||||
durationDiff = -durationDiff
|
|
||||||
}
|
|
||||||
if durationDiff <= tolerance {
|
|
||||||
durationMatches = append(durationMatches, track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(durationMatches) > 0 {
|
|
||||||
|
|
||||||
bestMatch = durationMatches[0]
|
|
||||||
for _, track := range durationMatches {
|
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
|
||||||
if tag == "HIRES_LOSSLESS" {
|
|
||||||
bestMatch = track
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("Found via duration match: %s - %s (%s)\n",
|
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
|
||||||
return bestMatch, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bestMatch = &allTracks[0]
|
|
||||||
for i := range allTracks {
|
|
||||||
track := &allTracks[i]
|
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
|
||||||
if tag == "HIRES_LOSSLESS" {
|
|
||||||
bestMatch = track
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bestMatch != &allTracks[0] {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
|
||||||
|
|
||||||
return bestMatch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsQuery(queries []string, query string) bool {
|
|
||||||
for _, q := range queries {
|
|
||||||
if q == query {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
@@ -925,28 +740,35 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
|
|||||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SegmentTemplate struct {
|
||||||
|
Initialization string `xml:"initialization,attr"`
|
||||||
|
Media string `xml:"media,attr"`
|
||||||
|
Timeline struct {
|
||||||
|
Segments []struct {
|
||||||
|
Duration int64 `xml:"d,attr"`
|
||||||
|
Repeat int `xml:"r,attr"`
|
||||||
|
} `xml:"S"`
|
||||||
|
} `xml:"SegmentTimeline"`
|
||||||
|
}
|
||||||
|
|
||||||
type MPD struct {
|
type MPD struct {
|
||||||
XMLName xml.Name `xml:"MPD"`
|
XMLName xml.Name `xml:"MPD"`
|
||||||
Period struct {
|
Period struct {
|
||||||
AdaptationSet struct {
|
AdaptationSets []struct {
|
||||||
Representation struct {
|
MimeType string `xml:"mimeType,attr"`
|
||||||
SegmentTemplate struct {
|
Codecs string `xml:"codecs,attr"`
|
||||||
Initialization string `xml:"initialization,attr"`
|
Representations []struct {
|
||||||
Media string `xml:"media,attr"`
|
ID string `xml:"id,attr"`
|
||||||
Timeline struct {
|
Codecs string `xml:"codecs,attr"`
|
||||||
Segments []struct {
|
Bandwidth int `xml:"bandwidth,attr"`
|
||||||
Duration int `xml:"d,attr"`
|
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||||
Repeat int `xml:"r,attr"`
|
|
||||||
} `xml:"S"`
|
|
||||||
} `xml:"SegmentTimeline"`
|
|
||||||
} `xml:"SegmentTemplate"`
|
|
||||||
} `xml:"Representation"`
|
} `xml:"Representation"`
|
||||||
|
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||||
} `xml:"AdaptationSet"`
|
} `xml:"AdaptationSet"`
|
||||||
} `xml:"Period"`
|
} `xml:"Period"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||||
|
|
||||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
||||||
@@ -954,8 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
manifestStr := string(manifestBytes)
|
manifestStr := string(manifestBytes)
|
||||||
|
|
||||||
if strings.HasPrefix(manifestStr, "{") {
|
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
|
||||||
|
|
||||||
var btsManifest TidalBTSManifest
|
var btsManifest TidalBTSManifest
|
||||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
||||||
@@ -972,25 +793,78 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
fmt.Println("Manifest: DASH format")
|
fmt.Println("Manifest: DASH format")
|
||||||
|
|
||||||
var mpd MPD
|
var mpd MPD
|
||||||
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
var segTemplate *SegmentTemplate
|
||||||
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
|
||||||
|
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
||||||
|
var selectedBandwidth int
|
||||||
|
var selectedCodecs string
|
||||||
|
|
||||||
|
for _, as := range mpd.Period.AdaptationSets {
|
||||||
|
|
||||||
|
if as.SegmentTemplate != nil {
|
||||||
|
|
||||||
|
if segTemplate == nil {
|
||||||
|
segTemplate = as.SegmentTemplate
|
||||||
|
selectedCodecs = as.Codecs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rep := range as.Representations {
|
||||||
|
if rep.SegmentTemplate != nil {
|
||||||
|
if rep.Bandwidth > selectedBandwidth {
|
||||||
|
selectedBandwidth = rep.Bandwidth
|
||||||
|
segTemplate = rep.SegmentTemplate
|
||||||
|
|
||||||
|
if rep.Codecs != "" {
|
||||||
|
selectedCodecs = rep.Codecs
|
||||||
|
} else {
|
||||||
|
selectedCodecs = as.Codecs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedBandwidth > 0 {
|
||||||
|
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate
|
var mediaTemplate string
|
||||||
initURL = segTemplate.Initialization
|
segmentCount := 0
|
||||||
mediaTemplate := segTemplate.Media
|
|
||||||
|
|
||||||
if initURL == "" || mediaTemplate == "" {
|
if segTemplate != nil {
|
||||||
|
initURL = segTemplate.Initialization
|
||||||
|
mediaTemplate = segTemplate.Media
|
||||||
|
|
||||||
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
for _, seg := range segTemplate.Timeline.Segments {
|
||||||
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
segmentCount += seg.Repeat + 1
|
||||||
|
|
||||||
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
|
||||||
initURL = match[1]
|
|
||||||
}
|
}
|
||||||
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
}
|
||||||
mediaTemplate = match[1]
|
|
||||||
|
if segmentCount > 0 && initURL != "" && mediaTemplate != "" {
|
||||||
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||||
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||||
|
|
||||||
|
fmt.Printf("Parsed manifest via XML: %d segments\n", segmentCount)
|
||||||
|
|
||||||
|
for i := 1; i <= segmentCount; i++ {
|
||||||
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
|
return "", initURL, mediaURLs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Using regex fallback for DASH manifest...")
|
||||||
|
|
||||||
|
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
||||||
|
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
||||||
|
|
||||||
|
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||||
|
initURL = match[1]
|
||||||
|
}
|
||||||
|
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||||
|
mediaTemplate = match[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
if initURL == "" {
|
if initURL == "" {
|
||||||
@@ -1000,23 +874,26 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||||
|
|
||||||
segmentCount := 0
|
segmentCount = 0
|
||||||
for _, seg := range segTemplate.Timeline.Segments {
|
|
||||||
segmentCount += seg.Repeat + 1
|
segTagRe := regexp.MustCompile(`<S\s+[^>]*>`)
|
||||||
|
matches := segTagRe.FindAllString(manifestStr, -1)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
repeat := 0
|
||||||
|
rRe := regexp.MustCompile(`r="(\d+)"`)
|
||||||
|
if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 {
|
||||||
|
fmt.Sscanf(rMatch[1], "%d", &repeat)
|
||||||
|
}
|
||||||
|
segmentCount += repeat + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if segmentCount == 0 {
|
if segmentCount == 0 {
|
||||||
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`)
|
return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
||||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
|
||||||
for _, match := range matches {
|
|
||||||
repeat := 0
|
|
||||||
if len(match) > 1 && match[1] != "" {
|
|
||||||
fmt.Sscanf(match[1], "%d", &repeat)
|
|
||||||
}
|
|
||||||
segmentCount += repeat + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
||||||
|
|
||||||
for i := 1; i <= segmentCount; i++ {
|
for i := 1; i <= segmentCount; i++ {
|
||||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
|
|||||||
+20
-21
@@ -1,23 +1,22 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js';
|
||||||
import globals from 'globals'
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint';
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]);
|
||||||
])
|
|
||||||
|
|||||||
+19
-14
@@ -1,16 +1,21 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<title>SpotiFLAC</title>
|
<link
|
||||||
</head>
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap"
|
||||||
<body>
|
rel="stylesheet">
|
||||||
<div id="root"></div>
|
<title>SpotiFLAC</title>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
</head>
|
||||||
</body>
|
|
||||||
</html>
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.24.12",
|
"motion": "^12.26.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
@@ -37,8 +37,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.8",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.52.0",
|
"typescript-eslint": "^8.53.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
be90455e8d3a26cf5c12d4fa0779bc1a
|
68754ba75ba7fe058dd9ebf6593e2759
|
||||||
Generated
+444
-444
File diff suppressed because it is too large
Load Diff
@@ -2,32 +2,23 @@ import sharp from 'sharp';
|
|||||||
import { readFileSync, mkdirSync } from 'fs';
|
import { readFileSync, mkdirSync } from 'fs';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const rootDir = join(__dirname, '..', '..');
|
const rootDir = join(__dirname, '..', '..');
|
||||||
|
|
||||||
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
|
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
|
||||||
const outputPath = join(rootDir, 'build', 'appicon.png');
|
const outputPath = join(rootDir, 'build', 'appicon.png');
|
||||||
|
|
||||||
async function generateIcon() {
|
async function generateIcon() {
|
||||||
try {
|
try {
|
||||||
// Ensure build directory exists
|
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
||||||
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
const svgBuffer = readFileSync(svgPath);
|
||||||
|
await sharp(svgBuffer)
|
||||||
// Read SVG
|
.resize(1024, 1024)
|
||||||
const svgBuffer = readFileSync(svgPath);
|
.png()
|
||||||
|
.toFile(outputPath);
|
||||||
// Convert SVG to PNG (1024x1024 for Wails)
|
console.log('✓ Icon generated:', outputPath);
|
||||||
await sharp(svgBuffer)
|
}
|
||||||
.resize(1024, 1024)
|
catch (error) {
|
||||||
.png()
|
console.error('✗ Failed to generate icon:', error.message);
|
||||||
.toFile(outputPath);
|
process.exit(1);
|
||||||
|
}
|
||||||
console.log('✓ Icon generated:', outputPath);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('✗ Failed to generate icon:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generateIcon();
|
generateIcon();
|
||||||
|
|||||||
+164
-155
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, X, ArrowUp } from "lucide-react";
|
import { Search, X, ArrowUp } from "lucide-react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||||
import { applyTheme } from "@/lib/themes";
|
import { applyTheme } from "@/lib/themes";
|
||||||
import { OpenFolder } from "../wailsjs/go/main/App";
|
import { OpenFolder } from "../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
@@ -50,22 +50,31 @@ function App() {
|
|||||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
||||||
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "7.0.1";
|
const CURRENT_VERSION = "7.0.5";
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
const lyrics = useLyrics();
|
const lyrics = useLyrics();
|
||||||
const cover = useCover();
|
const cover = useCover();
|
||||||
const availability = useAvailability();
|
const availability = useAvailability();
|
||||||
const downloadQueue = useDownloadQueueDialog();
|
const downloadQueue = useDownloadQueueDialog();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const savedSettings = getSettings();
|
||||||
|
if (savedSettings) {
|
||||||
|
applyThemeMode(savedSettings.themeMode);
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
applyFont(savedSettings.fontFamily);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initSettings = async () => {
|
const initSettings = async () => {
|
||||||
const settings = getSettings();
|
const settings = await loadSettings();
|
||||||
applyThemeMode(settings.themeMode);
|
applyThemeMode(settings.themeMode);
|
||||||
applyTheme(settings.theme);
|
applyTheme(settings.theme);
|
||||||
applyFont(settings.fontFamily);
|
applyFont(settings.fontFamily);
|
||||||
if (!settings.downloadPath) {
|
if (!settings.downloadPath) {
|
||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
saveSettings(settingsWithDefaults);
|
await saveSettings(settingsWithDefaults);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
initSettings();
|
initSettings();
|
||||||
@@ -190,7 +199,7 @@ function App() {
|
|||||||
url: spotifyUrl,
|
url: spotifyUrl,
|
||||||
type: "album",
|
type: "album",
|
||||||
name: album_info.name,
|
name: album_info.name,
|
||||||
artist: `${album_info.total_tracks} tracks`,
|
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
|
||||||
image: album_info.images,
|
image: album_info.images,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -200,7 +209,7 @@ function App() {
|
|||||||
url: spotifyUrl,
|
url: spotifyUrl,
|
||||||
type: "playlist",
|
type: "playlist",
|
||||||
name: playlist_info.owner.name,
|
name: playlist_info.owner.name,
|
||||||
artist: `${playlist_info.tracks.total} tracks`,
|
artist: `${playlist_info.tracks.total.toLocaleString()} tracks`,
|
||||||
image: playlist_info.cover || playlist_info.owner.images || "",
|
image: playlist_info.cover || playlist_info.owner.images || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -210,7 +219,7 @@ function App() {
|
|||||||
url: spotifyUrl,
|
url: spotifyUrl,
|
||||||
type: "artist",
|
type: "artist",
|
||||||
name: artist_info.name,
|
name: artist_info.name,
|
||||||
artist: `${artist_info.total_albums} albums`,
|
artist: `${artist_info.total_albums.toLocaleString()} albums`,
|
||||||
image: artist_info.images,
|
image: artist_info.images,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -253,49 +262,49 @@ 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);
|
||||||
}
|
}
|
||||||
}} onTrackClick={async (track) => {
|
}} onTrackClick={async (track) => {
|
||||||
if (track.external_urls) {
|
if (track.external_urls) {
|
||||||
setSpotifyUrl(track.external_urls);
|
setSpotifyUrl(track.external_urls);
|
||||||
await metadata.handleFetchMetadata(track.external_urls);
|
await metadata.handleFetchMetadata(track.external_urls);
|
||||||
}
|
}
|
||||||
}}/>);
|
}} />);
|
||||||
}
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
}} onTrackClick={async (track) => {
|
}} onTrackClick={async (track) => {
|
||||||
if (track.external_urls) {
|
if (track.external_urls) {
|
||||||
setSpotifyUrl(track.external_urls);
|
setSpotifyUrl(track.external_urls);
|
||||||
await metadata.handleFetchMetadata(track.external_urls);
|
await metadata.handleFetchMetadata(track.external_urls);
|
||||||
}
|
}
|
||||||
}}/>);
|
}} />);
|
||||||
}
|
}
|
||||||
if ("artist_info" in metadata.metadata) {
|
if ("artist_info" in metadata.metadata) {
|
||||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} 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, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} 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, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} 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);
|
||||||
}
|
}
|
||||||
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
|
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
|
||||||
if (track.external_urls) {
|
if (track.external_urls) {
|
||||||
setSpotifyUrl(track.external_urls);
|
setSpotifyUrl(track.external_urls);
|
||||||
await metadata.handleFetchMetadata(track.external_urls);
|
await metadata.handleFetchMetadata(track.external_urls);
|
||||||
}
|
}
|
||||||
}}/>);
|
}} />);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -328,7 +337,7 @@ function App() {
|
|||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case "settings":
|
case "settings":
|
||||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />;
|
||||||
case "debug":
|
case "debug":
|
||||||
return <DebugLoggerPage />;
|
return <DebugLoggerPage />;
|
||||||
case "audio-analysis":
|
case "audio-analysis":
|
||||||
@@ -339,133 +348,133 @@ function App() {
|
|||||||
return <FileManagerPage />;
|
return <FileManagerPage />;
|
||||||
default:
|
default:
|
||||||
return (<>
|
return (<>
|
||||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate} />
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
|
|
||||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
|
||||||
<div className="absolute right-4 top-4">
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
|
||||||
<X className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
|
||||||
with large discography.
|
|
||||||
</DialogDescription>
|
|
||||||
{metadata.pendingArtistName && (<div className="py-2">
|
|
||||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
|
||||||
</div>)}
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
|
||||||
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
|
||||||
minutes).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={metadata.handleConfirmFetch}>
|
|
||||||
<Search className="h-4 w-4"/>
|
|
||||||
Fetch
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
|
||||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
<div className="absolute right-4 top-4">
|
||||||
<div className="absolute right-4 top-4">
|
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
<X className="h-4 w-4" />
|
||||||
<X className="h-4 w-4"/>
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
<DialogDescription>
|
||||||
<DialogDescription>
|
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||||
Do you want to fetch metadata for this album?
|
with large discography.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
{metadata.selectedAlbum && (<div className="py-2">
|
{metadata.pendingArtistName && (<div className="py-2">
|
||||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||||
</div>)}
|
</div>)}
|
||||||
<DialogFooter>
|
<div className="space-y-4 py-4">
|
||||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
<div className="space-y-2">
|
||||||
Cancel
|
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||||
</Button>
|
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))} />
|
||||||
<Button onClick={async () => {
|
<p className="text-xs text-muted-foreground">
|
||||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||||
if (albumUrl) {
|
minutes).
|
||||||
setSpotifyUrl(albumUrl);
|
</p>
|
||||||
}
|
</div>
|
||||||
}}>
|
</div>
|
||||||
<Search className="h-4 w-4"/>
|
<DialogFooter>
|
||||||
Fetch Album
|
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
||||||
</Button>
|
Cancel
|
||||||
</DialogFooter>
|
</Button>
|
||||||
</DialogContent>
|
<Button onClick={metadata.handleConfirmFetch}>
|
||||||
</Dialog>
|
<Search className="h-4 w-4" />
|
||||||
|
Fetch
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
|
|
||||||
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Do you want to fetch metadata for this album?
|
||||||
|
</DialogDescription>
|
||||||
|
{metadata.selectedAlbum && (<div className="py-2">
|
||||||
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||||
|
</div>)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={async () => {
|
||||||
|
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||||
|
if (albumUrl) {
|
||||||
|
setSpotifyUrl(albumUrl);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Fetch Album
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
|
||||||
setSpotifyUrl(url);
|
setSpotifyUrl(url);
|
||||||
const updatedUrl = await metadata.handleFetchMetadata(url);
|
const updatedUrl = await metadata.handleFetchMetadata(url);
|
||||||
if (updatedUrl) {
|
if (updatedUrl) {
|
||||||
setSpotifyUrl(updatedUrl);
|
setSpotifyUrl(updatedUrl);
|
||||||
}
|
}
|
||||||
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/>
|
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} />
|
||||||
|
|
||||||
{!isSearchMode && metadata.metadata && renderMetadata()}
|
{!isSearchMode && metadata.metadata && renderMetadata()}
|
||||||
</>);
|
</>);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (<TooltipProvider>
|
return (<TooltipProvider>
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{renderPage()}
|
{renderPage()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||||
|
|
||||||
|
|
||||||
|
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue} />
|
||||||
|
|
||||||
|
|
||||||
|
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
|
||||||
|
<ArrowUp className="h-5 w-5" />
|
||||||
|
</Button>)}
|
||||||
|
|
||||||
|
|
||||||
|
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCancelNavigation}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDiscardChanges}>
|
||||||
|
Discard Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
|
||||||
|
|
||||||
|
|
||||||
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
|
|
||||||
|
|
||||||
|
|
||||||
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
|
|
||||||
<ArrowUp className="h-5 w-5"/>
|
|
||||||
</Button>)}
|
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
|
||||||
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleCancelNavigation}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDiscardChanges}>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>);
|
</TooltipProvider>);
|
||||||
}
|
}
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
<span>{albumInfo.release_date}</span>
|
<span>{albumInfo.release_date}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
{albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +101,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{onDownloadAllLyrics && (<Tooltip>
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{onDownloadAllLyrics && (<Tooltip>
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
|
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { backend } from "../../wailsjs/go/models";
|
import { backend } from "../../wailsjs/go/models";
|
||||||
interface DownloadQueueProps {
|
interface DownloadQueueProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -47,6 +48,17 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
console.error("Failed to clear history:", error);
|
console.error("Failed to clear history:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
await ClearAllDownloads();
|
||||||
|
const info = await GetDownloadQueue();
|
||||||
|
setQueueInfo(info);
|
||||||
|
toast.success("Download queue reset");
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to reset queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "downloading":
|
case "downloading":
|
||||||
@@ -72,8 +84,8 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
queued: "outline",
|
queued: "outline",
|
||||||
};
|
};
|
||||||
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||||
{status}
|
{status}
|
||||||
</Badge>);
|
</Badge>);
|
||||||
};
|
};
|
||||||
const formatDuration = (startTimestamp: number) => {
|
const formatDuration = (startTimestamp: number) => {
|
||||||
if (startTimestamp === 0)
|
if (startTimestamp === 0)
|
||||||
@@ -94,138 +106,138 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (<Dialog open={isOpen} onOpenChange={onClose}>
|
return (<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
||||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
<DialogTitle className="text-lg font-semibold hover:text-primary transition-colors cursor-pointer" onClick={handleReset}>Download Queue</DialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
|
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
|
||||||
<Trash2 className="h-3 w-3"/>
|
<Trash2 className="h-3 w-3"/>
|
||||||
Clear History
|
Clear History
|
||||||
</Button>)}
|
</Button>)}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||||
<X className="h-4 w-4"/>
|
<X className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
<span className="text-muted-foreground">Queued:</span>
|
<span className="text-muted-foreground">Queued:</span>
|
||||||
<span className="font-semibold">{queueInfo.queued_count}</span>
|
<span className="font-semibold">{queueInfo.queued_count}</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
|
||||||
<span className="text-muted-foreground">Completed:</span>
|
|
||||||
<span className="font-semibold">{queueInfo.completed_count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
|
||||||
<span className="text-muted-foreground">Skipped:</span>
|
|
||||||
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
|
||||||
<span className="text-muted-foreground">Failed:</span>
|
|
||||||
<span className="font-semibold">{queueInfo.failed_count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
||||||
|
<span className="text-muted-foreground">Completed:</span>
|
||||||
|
<span className="font-semibold">{queueInfo.completed_count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
||||||
|
<span className="text-muted-foreground">Skipped:</span>
|
||||||
|
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
||||||
|
<span className="text-muted-foreground">Failed:</span>
|
||||||
|
<span className="font-semibold">{queueInfo.failed_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
<span className="text-muted-foreground">Downloaded:</span>
|
<span className="text-muted-foreground">Downloaded:</span>
|
||||||
<span className="font-semibold font-mono">
|
<span className="font-semibold font-mono">
|
||||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
<span className="text-muted-foreground">Speed:</span>
|
<span className="text-muted-foreground">Speed:</span>
|
||||||
<span className="font-semibold font-mono">
|
<span className="font-semibold font-mono">
|
||||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
|
|
||||||
<span className="text-muted-foreground">Duration:</span>
|
|
||||||
<span className="font-semibold font-mono">
|
|
||||||
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
|
<span className="font-semibold font-mono">
|
||||||
|
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
|
||||||
<div className="space-y-2 py-4">
|
|
||||||
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
|
||||||
<p>No downloads in queue</p>
|
|
||||||
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
<div className="space-y-2 py-4">
|
||||||
<div className="flex-1 min-w-0">
|
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||||
<p className="font-medium truncate">{item.track_name}</p>
|
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p>No downloads in queue</p>
|
||||||
{item.artist_name}
|
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
|
||||||
{item.album_name && ` • ${item.album_name}`}
|
<div className="flex items-start gap-3">
|
||||||
</p>
|
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||||
</div>
|
|
||||||
{getStatusBadge(item.status)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
<span>
|
<div className="flex-1 min-w-0">
|
||||||
{item.progress > 0
|
<p className="font-medium truncate">{item.track_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{item.artist_name}
|
||||||
|
{item.album_name && ` • ${item.album_name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(item.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||||
|
<span>
|
||||||
|
{item.progress > 0
|
||||||
? `${item.progress.toFixed(2)} MB`
|
? `${item.progress.toFixed(2)} MB`
|
||||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||||
? "Downloading..."
|
? "Downloading..."
|
||||||
: "Starting..."}
|
: "Starting..."}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{item.speed > 0
|
{item.speed > 0
|
||||||
? `${item.speed.toFixed(2)} MB/s`
|
? `${item.speed.toFixed(2)} MB/s`
|
||||||
: queueInfo.current_speed > 0
|
: queueInfo.current_speed > 0
|
||||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
|
||||||
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||||
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
||||||
File already exists
|
</div>)}
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
|
||||||
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
|
||||||
{item.error_message}
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
||||||
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
File already exists
|
||||||
{item.file_path}
|
</div>)}
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
||||||
</div>)))}
|
{item.error_message}
|
||||||
</div>
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
|
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{item.file_path}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)))}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>);
|
</DialogContent>
|
||||||
|
</Dialog>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</div>
|
</div>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||||
@@ -110,7 +110,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{onDownloadAllLyrics && (<Tooltip>
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -8,354 +8,433 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||||
</svg>);
|
</svg>);
|
||||||
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||||
</svg>);
|
</svg>);
|
||||||
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||||
</svg>);
|
</svg>);
|
||||||
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 hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
const [showFFmpegWarning, setShowFFmpegWarning] = useState(false);
|
||||||
const resetToSaved = useCallback(() => {
|
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
|
||||||
const freshSavedSettings = getSettings();
|
const [installProgress, setInstallProgress] = useState(0);
|
||||||
flushSync(() => {
|
const downloadProgress = useDownloadProgress();
|
||||||
setTempSettings(freshSavedSettings);
|
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||||
setIsDark(document.documentElement.classList.contains('dark'));
|
const resetToSaved = useCallback(() => {
|
||||||
});
|
const freshSavedSettings = getSettings();
|
||||||
}, []);
|
flushSync(() => {
|
||||||
useEffect(() => {
|
setTempSettings(freshSavedSettings);
|
||||||
if (onResetRequest) {
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
onResetRequest(resetToSaved);
|
});
|
||||||
}
|
}, []);
|
||||||
}, [onResetRequest, resetToSaved]);
|
useEffect(() => {
|
||||||
useEffect(() => {
|
if (onResetRequest) {
|
||||||
onUnsavedChangesChange?.(hasUnsavedChanges);
|
onResetRequest(resetToSaved);
|
||||||
}, [hasUnsavedChanges, onUnsavedChangesChange]);
|
}
|
||||||
useEffect(() => {
|
}, [onResetRequest, resetToSaved]);
|
||||||
applyThemeMode(savedSettings.themeMode);
|
useEffect(() => {
|
||||||
|
onUnsavedChangesChange?.(hasUnsavedChanges);
|
||||||
|
}, [hasUnsavedChanges, onUnsavedChangesChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
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);
|
|
||||||
saveSettings(settingsWithDefaults);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadDefaults();
|
|
||||||
}, []);
|
|
||||||
const handleSave = () => {
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
return (<div className="space-y-6">
|
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
};
|
||||||
|
const handleInstallFFmpeg = async () => {
|
||||||
|
setIsInstallingFFmpeg(true);
|
||||||
|
setInstallProgress(0);
|
||||||
|
try {
|
||||||
|
const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App");
|
||||||
|
const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime");
|
||||||
|
EventsOn("ffmpeg:progress", (progress: number) => {
|
||||||
|
setInstallProgress(progress);
|
||||||
|
});
|
||||||
|
const response = await DownloadFFmpeg();
|
||||||
|
EventsOff("ffmpeg:progress");
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("FFmpeg installed successfully!");
|
||||||
|
setShowFFmpegWarning(false);
|
||||||
|
setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" }));
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<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>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="theme">Accent</Label>
|
||||||
|
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
||||||
|
<SelectTrigger id="theme">
|
||||||
|
<SelectValue placeholder="Select a theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full border border-border" style={{
|
||||||
|
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
||||||
|
}} />
|
||||||
|
{theme.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="font">Font</Label>
|
||||||
|
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||||
|
<SelectTrigger id="font">
|
||||||
|
<SelectValue placeholder="Select a font" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||||
|
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
||||||
|
</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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 }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
||||||
|
<SelectTrigger id="downloader" className="h-9 w-fit">
|
||||||
|
<SelectValue placeholder="Select a source" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
<SelectItem value="light">Light</SelectItem>
|
<SelectItem value="tidal">
|
||||||
<SelectItem value="dark">Dark</SelectItem>
|
<span className="flex items-center"><TidalIcon />Tidal</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz">
|
||||||
|
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon">
|
||||||
|
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||||
<div className="space-y-2">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<Label htmlFor="theme">Accent</Label>
|
<SelectValue />
|
||||||
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
|
||||||
<SelectTrigger id="theme">
|
|
||||||
<SelectValue placeholder="Select a theme"/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
|
||||||
<span className="flex items-center gap-2">
|
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
|
||||||
<span className="w-3 h-3 rounded-full border border-border" style={{
|
|
||||||
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
|
||||||
}}/>
|
|
||||||
{theme.label}
|
|
||||||
</span>
|
|
||||||
</SelectItem>))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>)}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
|
||||||
<div className="space-y-2">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<Label htmlFor="font">Font</Label>
|
<SelectValue />
|
||||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
|
||||||
<SelectTrigger id="font">
|
|
||||||
<SelectValue placeholder="Select a font"/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
|
||||||
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
<SelectItem value="7">FLAC 24-bit (Studio Quality)</SelectItem>
|
||||||
</SelectItem>))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>)}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{tempSettings.downloader === "amazon" && (
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
||||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
16-bit/44.1kHz or 24-bit/48kHz+
|
||||||
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
||||||
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))} />
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
|
||||||
<SelectTrigger id="downloader" className="h-9 w-fit">
|
|
||||||
<SelectValue placeholder="Select a source"/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
|
||||||
<SelectItem value="tidal">
|
|
||||||
<span className="flex items-center"><TidalIcon />Tidal</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="qobuz">
|
|
||||||
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon">
|
|
||||||
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
|
|
||||||
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>)}
|
|
||||||
|
|
||||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
|
|
||||||
<SelectItem value="7">FLAC 24-bit</SelectItem>
|
|
||||||
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>)}
|
|
||||||
|
|
||||||
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="HI_RES">Hi-Res (24-bit/96kHz+)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
|
||||||
<div className="flex items-center gap-6">
|
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))} />
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<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 }))}/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<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 }))}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t"/>
|
<div className="border-t" />
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<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>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</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>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{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>
|
|
||||||
{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>
|
|
||||||
</p>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t"/>
|
<div className="border-t" />
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<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>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</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>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{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>
|
|
||||||
{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>
|
|
||||||
</p>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
||||||
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-between pt-4 border-t">
|
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
|
||||||
<RotateCcw className="h-4 w-4"/>
|
|
||||||
Reset to Default
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} className="gap-1.5">
|
|
||||||
<Save className="h-4 w-4"/>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-between pt-4 border-t">
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
<RotateCcw className="h-4 w-4" />
|
||||||
<DialogHeader>
|
Reset to Default
|
||||||
<DialogTitle>Reset to Default?</DialogTitle>
|
</Button>
|
||||||
<DialogDescription>
|
<Button onClick={handleSave} className="gap-1.5">
|
||||||
This will reset all settings to their default values. Your custom configurations will be lost.
|
<Save className="h-4 w-4" />
|
||||||
</DialogDescription>
|
Save Changes
|
||||||
</DialogHeader>
|
</Button>
|
||||||
<DialogFooter>
|
</div>
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
|
||||||
<Button onClick={handleReset}>Reset</Button>
|
|
||||||
</DialogFooter>
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
</DialogContent>
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
</Dialog>
|
<DialogHeader>
|
||||||
</div>);
|
<DialogTitle>Reset to Default?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will reset all settings to their default values. Your custom configurations will be lost.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleReset}>Reset</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={showFFmpegWarning} onOpenChange={(open) => !isInstallingFFmpeg && setShowFFmpegWarning(open)}>
|
||||||
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>FFmpeg Required</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>Tidal 24-bit (Hi-Res Lossless) downloads audio in segmented files that need to be merged into a single FLAC file.</p>
|
||||||
|
<p>FFmpeg is required to merge these segments. {isInstallingFFmpeg ? "Installing FFmpeg..." : "Would you like to install FFmpeg now?"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInstallingFFmpeg && (<div className="space-y-2 py-2">
|
||||||
|
<div className="flex justify-between text-xs font-medium">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>Downloading & Extracting...</span>
|
||||||
|
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-muted-foreground font-normal">
|
||||||
|
{downloadProgress.mb_downloaded.toFixed(2)} MB
|
||||||
|
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<span>{installProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={installProgress} className="h-2" />
|
||||||
|
</div>)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{!isInstallingFFmpeg && (<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowFFmpegWarning(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleInstallFFmpeg}>Install FFmpeg</Button>
|
||||||
|
</DialogFooter>)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,12 +95,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<div className="mt-auto flex flex-col gap-2">
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?labels=bug&body=%23%23%23%20Problem%0AExplain%20the%20issue%20briefly.%0A%0A%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%20Spotify%20URL%0APaste%20the%20link%20here.%0A%0A%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS")}>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?title=%5BBug%20Report%5D%20/%20%5BFeature%20Request%5D&body=%3C%21--%20WARNING%3A%20Issues%20that%20do%20not%20follow%20this%20template%20will%20be%20closed%20without%20review.%20Fill%20out%20the%20relevant%20section%20and%20delete%20the%20other.%20--%3E%0A%0A%23%23%23%20%5BBug%20Report%5D%0A%0A%23%23%23%23%20Problem%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%23%20Spotify%20URL%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Version%0ASpotiFLAC%20v%0A%0A%23%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot%0A%0A---%0A%0A%23%23%23%20%5BFeature%20Request%5D%0A%0A%23%23%23%23%20Description%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Use%20Case%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot")}>
|
||||||
<GithubIcon size={20}/>
|
<GithubIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Report Bug</p>
|
<p>Report Bug or Feature Request</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
interface TrackInfoProps {
|
interface TrackInfoProps {
|
||||||
track: TrackMetadata & {
|
track: TrackMetadata & {
|
||||||
album_name: string;
|
album_name: string;
|
||||||
@@ -32,6 +33,7 @@ interface TrackInfoProps {
|
|||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
}
|
}
|
||||||
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
|
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
|
||||||
|
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||||
const formatDuration = (ms: number) => {
|
const formatDuration = (ms: number) => {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds = Math.floor((ms % 60000) / 1000);
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
@@ -44,96 +46,106 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
};
|
};
|
||||||
return (<Card>
|
return (<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
||||||
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
||||||
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
||||||
{formatDuration(track.duration_ms)}
|
{formatDuration(track.duration_ms)}
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-4 min-w-0">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
|
||||||
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
|
||||||
</div>
|
|
||||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
</div>)}
|
||||||
<div className="space-y-1">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Album</p>
|
|
||||||
<p className="font-medium truncate">{track.album_name}</p>
|
|
||||||
</div>
|
|
||||||
{track.plays && (<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Total Plays</p>
|
|
||||||
<p className="font-medium">{formatPlays(track.plays)}</p>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
|
||||||
<p className="font-medium">{track.release_date}</p>
|
|
||||||
</div>
|
|
||||||
{track.copyright && (<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Copyright</p>
|
|
||||||
<p className="font-medium truncate" title={track.copyright}>
|
|
||||||
{track.copyright}
|
|
||||||
</p>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
|
||||||
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
|
||||||
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
|
||||||
<Download className="h-4 w-4"/>
|
|
||||||
Download
|
|
||||||
</>)}
|
|
||||||
</Button>
|
|
||||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
|
||||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Download Lyric</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>)}
|
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingCover}>
|
|
||||||
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Download Cover</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>)}
|
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
|
|
||||||
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{availability ? (<div className="flex items-center gap-2">
|
|
||||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
</div>) : (<p>Check Availability</p>)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>)}
|
|
||||||
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
|
||||||
<FolderOpen className="h-4 w-4"/>
|
|
||||||
Open Folder
|
|
||||||
</Button>)}
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
</Card>);
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||||
|
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Album</p>
|
||||||
|
<p className="font-medium truncate">{track.album_name}</p>
|
||||||
|
</div>
|
||||||
|
{track.plays && (<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Plays</p>
|
||||||
|
<p className="font-medium">{formatPlays(track.plays)}</p>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||||
|
<p className="font-medium">{track.release_date}</p>
|
||||||
|
</div>
|
||||||
|
{track.copyright && (<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Copyright</p>
|
||||||
|
<p className="font-medium truncate" title={track.copyright}>
|
||||||
|
{track.copyright}
|
||||||
|
</p>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||||
|
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
||||||
|
<Download className="h-4 w-4"/>
|
||||||
|
Download
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
|
{track.spotify_id && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => playPreview(track.spotify_id!, track.name)} variant="outline" size="icon" disabled={loadingPreview === track.spotify_id}>
|
||||||
|
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||||
|
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Lyric</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingCover}>
|
||||||
|
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Cover</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" size="icon" disabled={checkingAvailability}>
|
||||||
|
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{availability ? (<div className="flex items-center gap-2">
|
||||||
|
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
</div>) : (<p>Check Availability</p>)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
|
Open Folder
|
||||||
|
</Button>)}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
interface TrackListProps {
|
interface TrackListProps {
|
||||||
tracks: TrackMetadata[];
|
tracks: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -52,6 +53,7 @@ interface TrackListProps {
|
|||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
|
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
|
||||||
|
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||||
let filteredTracks = tracks.filter((track) => {
|
let filteredTracks = tracks.filter((track) => {
|
||||||
if (!searchQuery)
|
if (!searchQuery)
|
||||||
return true;
|
return true;
|
||||||
@@ -118,6 +120,35 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const endIndex = startIndex + itemsPerPage;
|
const endIndex = startIndex + itemsPerPage;
|
||||||
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
||||||
|
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
|
||||||
|
if (total <= 10) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
|
pages.push(1);
|
||||||
|
if (current <= 7) {
|
||||||
|
for (let i = 2; i <= 10; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(total);
|
||||||
|
}
|
||||||
|
else if (current >= total - 7) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
for (let i = total - 9; i <= total; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(current - 1);
|
||||||
|
pages.push(current);
|
||||||
|
pages.push(current + 1);
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(total);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
||||||
const allSelected = tracksWithIsrc.length > 0 &&
|
const allSelected = tracksWithIsrc.length > 0 &&
|
||||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||||
@@ -135,192 +166,204 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
};
|
};
|
||||||
return (<div className="space-y-4">
|
return (<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||||
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||||
</th>)}
|
</th>)}
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||||
Title
|
Title
|
||||||
</th>
|
</th>
|
||||||
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||||
Album
|
Album
|
||||||
</th>)}
|
</th>)}
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||||
Duration
|
Duration
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
||||||
Plays
|
Plays
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||||
{showCheckboxes && (<td className="p-4 align-middle">
|
{showCheckboxes && (<td className="p-4 align-middle">
|
||||||
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
||||||
</td>)}
|
</td>)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span>{startIndex + index + 1}</span>
|
<span>{startIndex + index + 1}</span>
|
||||||
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
||||||
? "text-green-500"
|
? "text-green-500"
|
||||||
: track.status === "DOWN"
|
: track.status === "DOWN"
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: track.status === "NEW"
|
: track.status === "NEW"
|
||||||
? "text-blue-500"
|
? "text-blue-500"
|
||||||
: ""}`}>
|
: ""}`}>
|
||||||
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
||||||
</span>)}
|
</span>)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 align-middle">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
||||||
|
{track.name}
|
||||||
|
</span>) : (<span className="font-medium">{track.name}</span>)}
|
||||||
|
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<span className="text-sm text-muted-foreground">
|
||||||
<td className="p-4 align-middle">
|
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
|
||||||
{track.name}
|
|
||||||
</span>) : (<span className="font-medium">{track.name}</span>)}
|
|
||||||
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
|
||||||
const artistNames = track.artists.split(", ").map(name => name.trim());
|
const artistNames = track.artists.split(", ").map(name => name.trim());
|
||||||
return artistNames.map((name, i) => {
|
return artistNames.map((name, i) => {
|
||||||
const artistData = track.artists_data![i];
|
const artistData = track.artists_data![i];
|
||||||
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
||||||
return (<span key={artistData?.id || i}>
|
return (<span key={artistData?.id || i}>
|
||||||
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
id: artistData.id,
|
id: artistData.id,
|
||||||
name: name,
|
name: name,
|
||||||
external_urls: artistData.external_urls,
|
external_urls: artistData.external_urls,
|
||||||
})}>
|
})}>
|
||||||
{name}
|
{name}
|
||||||
</span>) : (name)}
|
</span>) : (name)}
|
||||||
{i < artistNames.length - 1 && ", "}
|
{i < artistNames.length - 1 && ", "}
|
||||||
</span>);
|
</span>);
|
||||||
});
|
});
|
||||||
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
id: track.artist_id!,
|
id: track.artist_id!,
|
||||||
name: track.artists,
|
name: track.artists,
|
||||||
external_urls: track.artist_url!,
|
external_urls: track.artist_url!,
|
||||||
})}>
|
})}>
|
||||||
{track.artists}
|
{track.artists}
|
||||||
</span>) : (track.artists)}
|
</span>) : (track.artists)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
||||||
id: track.album_id!,
|
id: track.album_id!,
|
||||||
name: track.album_name,
|
name: track.album_name,
|
||||||
external_urls: track.album_url!,
|
external_urls: track.album_url!,
|
||||||
})}>
|
})}>
|
||||||
{track.album_name}
|
{track.album_name}
|
||||||
</span>) : (track.album_name)}
|
</span>) : (track.album_name)}
|
||||||
</td>)}
|
</td>)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||||
{formatDuration(track.duration_ms)}
|
{formatDuration(track.duration_ms)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
||||||
{track.plays ? formatPlays(track.plays) : ""}
|
{track.plays ? formatPlays(track.plays) : ""}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle text-center">
|
<td className="p-4 align-middle text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{track.isrc && (<Tooltip>
|
{track.isrc && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="sm" disabled={isDownloading || downloadingTrack === track.isrc}>
|
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||||
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
{track.spotify_id && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="sm" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
<Button onClick={() => playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" disabled={loadingPreview === track.spotify_id}>
|
||||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Lyric</p>
|
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => {
|
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="icon" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||||
|
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Lyric</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => {
|
||||||
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||||
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
||||||
}} size="sm" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
|
}} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
|
||||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Cover</p>
|
<p>Download Cover</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
||||||
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
||||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||||
</div>) : (<p>Check Availability</p>)}
|
</div>) : (<p>Check Availability</p>)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (<Pagination>
|
{totalPages > 1 && (<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious href="#" onClick={(e) => {
|
<PaginationPrevious href="#" onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentPage > 1)
|
if (currentPage > 1)
|
||||||
onPageChange(currentPage - 1);
|
onPageChange(currentPage - 1);
|
||||||
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
|
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||||
<PaginationLink href="#" onClick={(e) => {
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>) : (<PaginationItem key={page}>
|
||||||
|
<PaginationLink href="#" onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onPageChange(page);
|
onPageChange(page);
|
||||||
}} isActive={currentPage === page} className="cursor-pointer">
|
}} isActive={currentPage === page} className="cursor-pointer">
|
||||||
{page}
|
{page}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>))}
|
</PaginationItem>)))}
|
||||||
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext href="#" onClick={(e) => {
|
<PaginationNext href="#" onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentPage < totalPages)
|
if (currentPage < totalPages)
|
||||||
onPageChange(currentPage + 1);
|
onPageChange(currentPage + 1);
|
||||||
}} className={currentPage === totalPages
|
}} className={currentPage === totalPages
|
||||||
? "pointer-events-none opacity-50"
|
? "pointer-events-none opacity-50"
|
||||||
: "cursor-pointer"}/>
|
: "cursor-pointer"}/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>)}
|
</Pagination>)}
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ const buttonVariants = cva("inline-flex items-center justify-center gap-2 whites
|
|||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "h-9 w-9 p-0",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "h-8 w-8 p-0",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "h-10 w-10 p-0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
import { motion, useAnimation } from 'motion/react';
|
import { motion, useAnimation } from 'motion/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface FileMusicIconHandle {
|
export interface FileMusicIconHandle {
|
||||||
startAnimation: () => void;
|
startAnimation: () => void;
|
||||||
stopAnimation: () => void;
|
stopAnimation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
|
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATH_VARIANTS: Variants = {
|
const PATH_VARIANTS: Variants = {
|
||||||
normal: {
|
normal: {
|
||||||
pathLength: 1,
|
pathLength: 1,
|
||||||
@@ -28,91 +25,40 @@ const PATH_VARIANTS: Variants = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(
|
const controls = useAnimation();
|
||||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
const isControlledRef = useRef(false);
|
||||||
const controls = useAnimation();
|
useImperativeHandle(ref, () => {
|
||||||
const isControlledRef = useRef(false);
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
useImperativeHandle(ref, () => {
|
startAnimation: () => controls.start('animate'),
|
||||||
isControlledRef.current = true;
|
stopAnimation: () => controls.start('normal'),
|
||||||
return {
|
};
|
||||||
startAnimation: () => controls.start('animate'),
|
});
|
||||||
stopAnimation: () => controls.start('normal'),
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
};
|
if (!isControlledRef.current) {
|
||||||
});
|
controls.start('animate');
|
||||||
|
}
|
||||||
const handleMouseEnter = useCallback(
|
else {
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
onMouseEnter?.(e);
|
||||||
if (!isControlledRef.current) {
|
}
|
||||||
controls.start('animate');
|
}, [controls, onMouseEnter]);
|
||||||
} else {
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
onMouseEnter?.(e);
|
if (!isControlledRef.current) {
|
||||||
}
|
controls.start('normal');
|
||||||
},
|
}
|
||||||
[controls, onMouseEnter]
|
else {
|
||||||
);
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
const handleMouseLeave = useCallback(
|
}, [controls, onMouseLeave]);
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
if (!isControlledRef.current) {
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
controls.start('normal');
|
<motion.path d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
} else {
|
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
onMouseLeave?.(e);
|
<motion.path d="M8 20v-7l3 1.474" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
}
|
<motion.circle cx="6" cy="20" r="2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
},
|
|
||||||
[controls, onMouseLeave]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(className)}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<motion.path
|
|
||||||
d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M14 2v5a1 1 0 0 0 1 1h5"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M8 20v-7l3 1.474"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx="6"
|
|
||||||
cy="20"
|
|
||||||
r="2"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
FileMusicIcon.displayName = 'FileMusicIcon';
|
FileMusicIcon.displayName = 'FileMusicIcon';
|
||||||
export { FileMusicIcon };
|
export { FileMusicIcon };
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
import { motion, useAnimation } from 'motion/react';
|
import { motion, useAnimation } from 'motion/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface FilePenIconHandle {
|
export interface FilePenIconHandle {
|
||||||
startAnimation: () => void;
|
startAnimation: () => void;
|
||||||
stopAnimation: () => void;
|
stopAnimation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
|
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATH_VARIANTS: Variants = {
|
const PATH_VARIANTS: Variants = {
|
||||||
normal: {
|
normal: {
|
||||||
pathLength: 1,
|
pathLength: 1,
|
||||||
@@ -28,83 +25,39 @@ const PATH_VARIANTS: Variants = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(
|
const controls = useAnimation();
|
||||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
const isControlledRef = useRef(false);
|
||||||
const controls = useAnimation();
|
useImperativeHandle(ref, () => {
|
||||||
const isControlledRef = useRef(false);
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
useImperativeHandle(ref, () => {
|
startAnimation: () => controls.start('animate'),
|
||||||
isControlledRef.current = true;
|
stopAnimation: () => controls.start('normal'),
|
||||||
return {
|
};
|
||||||
startAnimation: () => controls.start('animate'),
|
});
|
||||||
stopAnimation: () => controls.start('normal'),
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
};
|
if (!isControlledRef.current) {
|
||||||
});
|
controls.start('animate');
|
||||||
|
}
|
||||||
const handleMouseEnter = useCallback(
|
else {
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
onMouseEnter?.(e);
|
||||||
if (!isControlledRef.current) {
|
}
|
||||||
controls.start('animate');
|
}, [controls, onMouseEnter]);
|
||||||
} else {
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
onMouseEnter?.(e);
|
if (!isControlledRef.current) {
|
||||||
}
|
controls.start('normal');
|
||||||
},
|
}
|
||||||
[controls, onMouseEnter]
|
else {
|
||||||
);
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
const handleMouseLeave = useCallback(
|
}, [controls, onMouseLeave]);
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
if (!isControlledRef.current) {
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
controls.start('normal');
|
<motion.path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
} else {
|
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
onMouseLeave?.(e);
|
<motion.path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
}
|
|
||||||
},
|
|
||||||
[controls, onMouseLeave]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(className)}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<motion.path
|
|
||||||
d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M14 2v5a1 1 0 0 0 1 1h5"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
FilePenIcon.displayName = 'FilePenIcon';
|
FilePenIcon.displayName = 'FilePenIcon';
|
||||||
export { FilePenIcon };
|
export { FilePenIcon };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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...`);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
export function usePreview() {
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
|
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
||||||
|
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
|
||||||
|
const playPreview = async (trackId: string, trackName: string) => {
|
||||||
|
try {
|
||||||
|
if (playingTrack === trackId && currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setPlayingTrack(null);
|
||||||
|
setCurrentAudio(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setCurrentAudio(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
}
|
||||||
|
setLoadingPreview(trackId);
|
||||||
|
const previewURL = await GetPreviewURL(trackId);
|
||||||
|
if (!previewURL) {
|
||||||
|
toast.error("Preview not available", {
|
||||||
|
description: `No preview found for "${trackName}"`,
|
||||||
|
});
|
||||||
|
setLoadingPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const audio = new Audio(previewURL);
|
||||||
|
audio.addEventListener("loadeddata", () => {
|
||||||
|
setLoadingPreview(null);
|
||||||
|
setPlayingTrack(trackId);
|
||||||
|
});
|
||||||
|
audio.addEventListener("ended", () => {
|
||||||
|
setPlayingTrack(null);
|
||||||
|
setCurrentAudio(null);
|
||||||
|
});
|
||||||
|
audio.addEventListener("error", () => {
|
||||||
|
toast.error("Failed to play preview", {
|
||||||
|
description: `Could not play preview for "${trackName}"`,
|
||||||
|
});
|
||||||
|
setLoadingPreview(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
setCurrentAudio(null);
|
||||||
|
});
|
||||||
|
setCurrentAudio(audio);
|
||||||
|
await audio.play();
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error("Preview error:", error);
|
||||||
|
toast.error("Preview not available", {
|
||||||
|
description: error?.message || `Could not load preview for "${trackName}"`,
|
||||||
|
});
|
||||||
|
setLoadingPreview(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stopPreview = () => {
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setCurrentAudio(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
playPreview,
|
||||||
|
stopPreview,
|
||||||
|
loadingPreview,
|
||||||
|
playingTrack,
|
||||||
|
};
|
||||||
|
}
|
||||||
+24
-11
@@ -26,6 +26,7 @@
|
|||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
--font-sans: "Bricolage Grotesque", "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -75,11 +76,15 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
code, pre, .font-mono {
|
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
.font-mono {
|
||||||
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,43 +139,51 @@
|
|||||||
/* Specific color for each toast type - match icon color */
|
/* Specific color for each toast type - match icon color */
|
||||||
[data-sonner-toast][data-type="success"] [data-description],
|
[data-sonner-toast][data-type="success"] [data-description],
|
||||||
[data-sonner-toast][data-type="success"] [data-description] * {
|
[data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
color: rgb(22 163 74) !important; /* green-600 - same as icon */
|
color: rgb(22 163 74) !important;
|
||||||
|
/* green-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sonner-toast][data-type="error"] [data-description],
|
[data-sonner-toast][data-type="error"] [data-description],
|
||||||
[data-sonner-toast][data-type="error"] [data-description] * {
|
[data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
color: rgb(220 38 38) !important; /* red-600 - same as icon */
|
color: rgb(220 38 38) !important;
|
||||||
|
/* red-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sonner-toast][data-type="warning"] [data-description],
|
[data-sonner-toast][data-type="warning"] [data-description],
|
||||||
[data-sonner-toast][data-type="warning"] [data-description] * {
|
[data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
|
color: rgb(202 138 4) !important;
|
||||||
|
/* yellow-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sonner-toast][data-type="info"] [data-description],
|
[data-sonner-toast][data-type="info"] [data-description],
|
||||||
[data-sonner-toast][data-type="info"] [data-description] * {
|
[data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
|
color: rgb(37 99 235) !important;
|
||||||
|
/* blue-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode - use same icon colors */
|
/* Dark mode - use same icon colors */
|
||||||
.dark [data-sonner-toast][data-type="success"] [data-description],
|
.dark [data-sonner-toast][data-type="success"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="success"] [data-description] * {
|
.dark [data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
color: rgb(22 163 74) !important; /* green-600 */
|
color: rgb(22 163 74) !important;
|
||||||
|
/* green-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sonner-toast][data-type="error"] [data-description],
|
.dark [data-sonner-toast][data-type="error"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="error"] [data-description] * {
|
.dark [data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
color: rgb(220 38 38) !important; /* red-600 */
|
color: rgb(220 38 38) !important;
|
||||||
|
/* red-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sonner-toast][data-type="warning"] [data-description],
|
.dark [data-sonner-toast][data-type="warning"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
|
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
color: rgb(202 138 4) !important; /* yellow-600 */
|
color: rgb(202 138 4) !important;
|
||||||
|
/* yellow-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sonner-toast][data-type="info"] [data-description],
|
.dark [data-sonner-toast][data-type="info"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="info"] [data-description] * {
|
.dark [data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
color: rgb(37 99 235) !important; /* blue-600 */
|
color: rgb(37 99 235) !important;
|
||||||
|
/* blue-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode toast styling */
|
/* Dark mode toast styling */
|
||||||
@@ -252,4 +265,4 @@
|
|||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.2);
|
||||||
}
|
}
|
||||||
+117
-30
@@ -1,5 +1,5 @@
|
|||||||
import { GetDefaults } from "../../wailsjs/go/main/App";
|
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
|
||||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans";
|
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
||||||
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
||||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -21,8 +21,8 @@ export interface Settings {
|
|||||||
embedMaxQualityCover: boolean;
|
embedMaxQualityCover: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
qobuzQuality: "6" | "7" | "27";
|
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,30 +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: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
|
||||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
||||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", 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' },
|
||||||
|
];
|
||||||
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) {
|
||||||
@@ -137,7 +138,8 @@ async function fetchDefaultPath(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const SETTINGS_KEY = "spotiflac-settings";
|
const SETTINGS_KEY = "spotiflac-settings";
|
||||||
export function getSettings(): Settings {
|
let cachedSettings: Settings | null = null;
|
||||||
|
function getSettingsFromLocalStorage(): Settings {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
@@ -188,17 +190,99 @@ export function getSettings(): Settings {
|
|||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
|
if (parsed.qobuzQuality === "27") {
|
||||||
|
parsed.qobuzQuality = "6";
|
||||||
|
}
|
||||||
if (!('amazonQuality' in parsed)) {
|
if (!('amazonQuality' in parsed)) {
|
||||||
parsed.amazonQuality = "HI_RES";
|
parsed.amazonQuality = "original";
|
||||||
}
|
}
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to load settings:", error);
|
console.error("Failed to load settings from local storage:", error);
|
||||||
}
|
}
|
||||||
return DEFAULT_SETTINGS;
|
return DEFAULT_SETTINGS;
|
||||||
}
|
}
|
||||||
|
export function getSettings(): Settings {
|
||||||
|
if (cachedSettings)
|
||||||
|
return cachedSettings;
|
||||||
|
return getSettingsFromLocalStorage();
|
||||||
|
}
|
||||||
|
export async function loadSettings(): Promise<Settings> {
|
||||||
|
try {
|
||||||
|
const backendSettings = await LoadSettings();
|
||||||
|
if (backendSettings) {
|
||||||
|
const parsed = backendSettings as any;
|
||||||
|
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||||
|
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||||
|
delete parsed.darkMode;
|
||||||
|
}
|
||||||
|
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
||||||
|
const hasArtist = parsed.artistSubfolder;
|
||||||
|
const hasAlbum = parsed.albumSubfolder;
|
||||||
|
if (hasArtist && hasAlbum) {
|
||||||
|
parsed.folderPreset = "artist-album";
|
||||||
|
parsed.folderTemplate = "{artist}/{album}";
|
||||||
|
}
|
||||||
|
else if (hasArtist) {
|
||||||
|
parsed.folderPreset = "artist";
|
||||||
|
parsed.folderTemplate = "{artist}";
|
||||||
|
}
|
||||||
|
else if (hasAlbum) {
|
||||||
|
parsed.folderPreset = "album";
|
||||||
|
parsed.folderTemplate = "{album}";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parsed.folderPreset = "none";
|
||||||
|
parsed.folderTemplate = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
||||||
|
const format = parsed.filenameFormat;
|
||||||
|
if (format === "title-artist") {
|
||||||
|
parsed.filenamePreset = "artist-title";
|
||||||
|
parsed.filenameTemplate = "{artist} - {title}";
|
||||||
|
}
|
||||||
|
else if (format === "artist-title") {
|
||||||
|
parsed.filenamePreset = "artist-title";
|
||||||
|
parsed.filenameTemplate = "{artist} - {title}";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parsed.filenamePreset = "title";
|
||||||
|
parsed.filenameTemplate = "{title}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.operatingSystem = detectOS();
|
||||||
|
if (!('tidalQuality' in parsed)) {
|
||||||
|
parsed.tidalQuality = "LOSSLESS";
|
||||||
|
}
|
||||||
|
if (!('qobuzQuality' in parsed)) {
|
||||||
|
parsed.qobuzQuality = "6";
|
||||||
|
}
|
||||||
|
if (parsed.qobuzQuality === "27") {
|
||||||
|
parsed.qobuzQuality = "6";
|
||||||
|
}
|
||||||
|
if (!('amazonQuality' in parsed)) {
|
||||||
|
parsed.amazonQuality = "original";
|
||||||
|
}
|
||||||
|
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
return cachedSettings!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to load settings from backend:", error);
|
||||||
|
}
|
||||||
|
const local = getSettingsFromLocalStorage();
|
||||||
|
try {
|
||||||
|
await SaveToBackend(local as any);
|
||||||
|
cachedSettings = local;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to migrate settings to backend:", error);
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}
|
||||||
export interface TemplateData {
|
export interface TemplateData {
|
||||||
artist?: string;
|
artist?: string;
|
||||||
album?: string;
|
album?: string;
|
||||||
@@ -224,30 +308,33 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
export async function getSettingsWithDefaults(): Promise<Settings> {
|
export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||||
const settings = getSettings();
|
const settings = await loadSettings();
|
||||||
if (!settings.downloadPath) {
|
if (!settings.downloadPath) {
|
||||||
settings.downloadPath = await fetchDefaultPath();
|
settings.downloadPath = await fetchDefaultPath();
|
||||||
|
await saveSettings(settings);
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
export function saveSettings(settings: Settings): void {
|
export async function saveSettings(settings: Settings): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
cachedSettings = settings;
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
await SaveToBackend(settings as any);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to save settings:", error);
|
console.error("Failed to save settings:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function updateSettings(partial: Partial<Settings>): Settings {
|
export async function updateSettings(partial: Partial<Settings>): Promise<Settings> {
|
||||||
const current = getSettings();
|
const current = getSettings();
|
||||||
const updated = { ...current, ...partial };
|
const updated = { ...current, ...partial };
|
||||||
saveSettings(updated);
|
await saveSettings(updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||||
const defaultPath = await fetchDefaultPath();
|
const defaultPath = await fetchDefaultPath();
|
||||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||||
saveSettings(defaultSettings);
|
await saveSettings(defaultSettings);
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
}
|
}
|
||||||
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-12
@@ -1,14 +1,12 @@
|
|||||||
import path from "path"
|
import path from "path";
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ require (
|
|||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
github.com/mewkiz/flac v1.0.13
|
github.com/mewkiz/flac v1.0.13
|
||||||
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
|||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||||
@@ -57,11 +60,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
|||||||
+2
-3
@@ -12,9 +12,8 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.0.1",
|
"productVersion": "7.0.5",
|
||||||
"copyright": "© 2026 afkarxyz",
|
"copyright": "© 2026 afkarxyz"
|
||||||
"comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required."
|
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user