v7.0.8
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
github: afkarxyz
|
github: afkarxyz
|
||||||
ko_fi: afkarxyz
|
ko_fi: afkarxyz
|
||||||
|
buy_me_a_coffee: afkarxyz
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
[](https://t.me/spotiflac)
|
[](https://t.me/spotiflac)
|
||||||
[](https://t.me/spotiflac_chat)
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
<!--  -->
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -78,7 +76,8 @@ _If this software is useful and brings you value,
|
|||||||
consider supporting the project by buying me a coffee.
|
consider supporting the project by buying me a coffee.
|
||||||
Your support helps keep development going._
|
Your support helps keep development going._
|
||||||
|
|
||||||
[](https://ko-fi.com/afkarxyz)
|
[](https://ko-fi.com/afkarxyz)
|
||||||
|
[](https://www.buymeacoffee.com/afkarxyz)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,27 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second)))
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second)))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
settings, err := a.LoadSettings()
|
||||||
|
|
||||||
|
if err == nil && settings != nil {
|
||||||
|
if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
|
||||||
|
if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
|
||||||
|
|
||||||
|
data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonData), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
||||||
@@ -592,6 +613,76 @@ func (a *App) CancelAllQueuedItems() {
|
|||||||
backend.CancelAllQueuedItems()
|
backend.CancelAllQueuedItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportFailedDownloads() (string, error) {
|
||||||
|
queueInfo := backend.GetDownloadQueue()
|
||||||
|
var failedItems []string
|
||||||
|
|
||||||
|
hasFailed := false
|
||||||
|
for _, item := range queueInfo.Queue {
|
||||||
|
if item.Status == backend.StatusFailed {
|
||||||
|
hasFailed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasFailed {
|
||||||
|
return "No failed downloads to export.", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
failedItems = append(failedItems, fmt.Sprintf("Failed Downloads Report - %s", time.Now().Format("2006-01-02 15:04:05")))
|
||||||
|
failedItems = append(failedItems, strings.Repeat("-", 50))
|
||||||
|
failedItems = append(failedItems, "")
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, item := range queueInfo.Queue {
|
||||||
|
if item.Status == backend.StatusFailed {
|
||||||
|
count++
|
||||||
|
line := fmt.Sprintf("%d. %s - %s", count, item.TrackName, item.ArtistName)
|
||||||
|
if item.AlbumName != "" {
|
||||||
|
line += fmt.Sprintf(" (%s)", item.AlbumName)
|
||||||
|
}
|
||||||
|
failedItems = append(failedItems, line)
|
||||||
|
failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage))
|
||||||
|
|
||||||
|
if item.ISRC != "" {
|
||||||
|
failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.ISRC))
|
||||||
|
if !strings.HasPrefix(item.ISRC, "http") {
|
||||||
|
failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.ISRC))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failedItems = append(failedItems, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.Join(failedItems, "\n")
|
||||||
|
defaultFilename := fmt.Sprintf("SpotiFLAC_%s_Failed.txt", time.Now().Format("20060102_150405"))
|
||||||
|
|
||||||
|
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
DefaultFilename: defaultFilename,
|
||||||
|
Title: "Export Failed Downloads",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "Text Files (*.txt)",
|
||||||
|
Pattern: "*.txt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open save dialog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return "Export cancelled", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Quit() {
|
func (a *App) Quit() {
|
||||||
|
|
||||||
panic("quit")
|
panic("quit")
|
||||||
@@ -1091,12 +1182,15 @@ type CheckFileExistenceResult struct {
|
|||||||
ArtistName string `json:"artist_name,omitempty"`
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
|
func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
|
||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
return []CheckFileExistenceResult{}
|
return []CheckFileExistenceResult{}
|
||||||
}
|
}
|
||||||
|
|
||||||
outputDir = backend.NormalizePath(outputDir)
|
outputDir = backend.NormalizePath(outputDir)
|
||||||
|
if rootDir != "" {
|
||||||
|
rootDir = backend.NormalizePath(rootDir)
|
||||||
|
}
|
||||||
|
|
||||||
defaultFilenameFormat := "title-artist"
|
defaultFilenameFormat := "title-artist"
|
||||||
|
|
||||||
@@ -1107,6 +1201,30 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
|||||||
|
|
||||||
resultsChan := make(chan result, len(tracks))
|
resultsChan := make(chan result, len(tracks))
|
||||||
|
|
||||||
|
var rootDirFiles map[string]string
|
||||||
|
rootDirFilesOnce := false
|
||||||
|
getRootDirFiles := func() map[string]string {
|
||||||
|
if rootDirFilesOnce {
|
||||||
|
return rootDirFiles
|
||||||
|
}
|
||||||
|
rootDirFiles = make(map[string]string)
|
||||||
|
if rootDir != "" && rootDir != outputDir {
|
||||||
|
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
|
||||||
|
rootDirFiles[info.Name()] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rootDirFilesOnce = true
|
||||||
|
return rootDirFiles
|
||||||
|
}
|
||||||
|
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
go func(idx int, t CheckFileExistenceRequest) {
|
go func(idx int, t CheckFileExistenceRequest) {
|
||||||
res := CheckFileExistenceResult{
|
res := CheckFileExistenceResult{
|
||||||
@@ -1163,6 +1281,9 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
|||||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||||
res.Exists = true
|
res.Exists = true
|
||||||
res.FilePath = expectedPath
|
res.FilePath = expectedPath
|
||||||
|
} else {
|
||||||
|
|
||||||
|
res.FilePath = expectedFilename
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsChan <- result{index: idx, result: res}
|
resultsChan <- result{index: idx, result: res}
|
||||||
@@ -1170,9 +1291,39 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
|||||||
}
|
}
|
||||||
|
|
||||||
results := make([]CheckFileExistenceResult, len(tracks))
|
results := make([]CheckFileExistenceResult, len(tracks))
|
||||||
|
missingIndices := []int{}
|
||||||
|
|
||||||
for i := 0; i < len(tracks); i++ {
|
for i := 0; i < len(tracks); i++ {
|
||||||
r := <-resultsChan
|
r := <-resultsChan
|
||||||
results[r.index] = r.result
|
results[r.index] = r.result
|
||||||
|
if !results[r.index].Exists {
|
||||||
|
missingIndices = append(missingIndices, r.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missingIndices) > 0 && rootDir != "" {
|
||||||
|
filesMap := getRootDirFiles()
|
||||||
|
if len(filesMap) > 0 {
|
||||||
|
for _, idx := range missingIndices {
|
||||||
|
|
||||||
|
expectedFilename := results[idx].FilePath
|
||||||
|
baseName := filepath.Base(expectedFilename)
|
||||||
|
if path, ok := filesMap[baseName]; ok {
|
||||||
|
results[idx].Exists = true
|
||||||
|
results[idx].FilePath = path
|
||||||
|
} else {
|
||||||
|
results[idx].FilePath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, idx := range missingIndices {
|
||||||
|
results[idx].FilePath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, idx := range missingIndices {
|
||||||
|
results[idx].FilePath = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -1245,3 +1396,52 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
|
|||||||
func (a *App) GetOSInfo() (string, error) {
|
func (a *App) GetOSInfo() (string, error) {
|
||||||
return backend.GetOSInfo()
|
return backend.GetOSInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
|
||||||
|
if len(filePaths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fnName := m3u8Name
|
||||||
|
|
||||||
|
safeName := backend.SanitizeFilename(fnName)
|
||||||
|
if safeName == "" {
|
||||||
|
safeName = "playlist"
|
||||||
|
}
|
||||||
|
|
||||||
|
m3u8Path := filepath.Join(outputDir, safeName+".m3u8")
|
||||||
|
|
||||||
|
f, err := os.Create(m3u8Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.WriteString("#EXTM3U\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range filePaths {
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(outputDir, path)
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
relPath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
|
||||||
|
if _, err := f.WriteString(relPath + "\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+129
-25
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -25,13 +26,9 @@ type SongLinkResponse struct {
|
|||||||
} `json:"linksByPlatform"`
|
} `json:"linksByPlatform"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AfkarXYZResponse struct {
|
type AmazonStreamResponse struct {
|
||||||
Success bool `json:"success"`
|
StreamURL string `json:"streamUrl"`
|
||||||
Data struct {
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
DirectLink string `json:"direct_link"`
|
|
||||||
FileName string `json:"file_name"`
|
|
||||||
FileSize int64 `json:"file_size"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
@@ -55,6 +52,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
fmt.Println("Getting Amazon URL...")
|
fmt.Println("Getting Amazon URL...")
|
||||||
|
|
||||||
@@ -108,13 +106,21 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
||||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
|
||||||
|
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
|
||||||
|
asin := asinRegex.FindString(amazonURL)
|
||||||
|
if asin == "" {
|
||||||
|
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
fmt.Printf("Fetching from AfkarXYZ...\n")
|
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -122,27 +128,25 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
var apiResp AfkarXYZResponse
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp AmazonStreamResponse
|
||||||
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
if apiResp.StreamURL == "" {
|
||||||
return "", fmt.Errorf("AfkarXYZ failed or no link found")
|
return "", fmt.Errorf("no stream URL found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := apiResp.Data.DirectLink
|
downloadURL := apiResp.StreamURL
|
||||||
fileName := apiResp.Data.FileName
|
fileName := fmt.Sprintf("%s.m4a", asin)
|
||||||
if fileName == "" {
|
|
||||||
fileName = "track.flac"
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
|
||||||
filePath := filepath.Join(outputDir, fileName)
|
filePath := filepath.Join(outputDir, fileName)
|
||||||
|
|
||||||
out, err := os.Create(filePath)
|
out, err := os.Create(filePath)
|
||||||
@@ -152,6 +156,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
||||||
|
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
dlResp, err := a.client.Do(dlReq)
|
dlResp, err := a.client.Do(dlReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,7 +164,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
}
|
}
|
||||||
defer dlResp.Body.Close()
|
defer dlResp.Body.Close()
|
||||||
|
|
||||||
fmt.Printf("Downloading from AfkarXYZ: %s\n", fileName)
|
fmt.Printf("Downloading track: %s\n", fileName)
|
||||||
pw := NewProgressWriter(out)
|
pw := NewProgressWriter(out)
|
||||||
_, err = io.Copy(pw, dlResp.Body)
|
_, err = io.Copy(pw, dlResp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,6 +174,86 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
|
|
||||||
|
if apiResp.DecryptionKey != "" {
|
||||||
|
fmt.Printf("Decrypting file...\n")
|
||||||
|
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
var codec string
|
||||||
|
if err == nil {
|
||||||
|
cmdProbe := exec.Command(ffprobePath,
|
||||||
|
"-v", "quiet",
|
||||||
|
"-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=codec_name",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
filePath,
|
||||||
|
)
|
||||||
|
setHideWindow(cmdProbe)
|
||||||
|
codecOutput, _ := cmdProbe.Output()
|
||||||
|
codec = strings.TrimSpace(string(codecOutput))
|
||||||
|
fmt.Printf("Detected codec: %s\n", codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetExt := ".m4a"
|
||||||
|
if codec == "flac" {
|
||||||
|
targetExt = ".flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptedFilename := "dec_" + fileName + targetExt
|
||||||
|
|
||||||
|
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
|
||||||
|
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptedPath := filepath.Join(outputDir, decryptedFilename)
|
||||||
|
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(apiResp.DecryptionKey)
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath,
|
||||||
|
"-decryption_key", key,
|
||||||
|
"-i", filePath,
|
||||||
|
"-c", "copy",
|
||||||
|
"-y",
|
||||||
|
decryptedPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
outStr := string(output)
|
||||||
|
if len(outStr) > 500 {
|
||||||
|
outStr = outStr[len(outStr)-500:]
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
|
||||||
|
return "", fmt.Errorf("decrypted file missing or empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
|
||||||
|
if err := os.Rename(decryptedPath, finalPath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
|
||||||
|
}
|
||||||
|
filePath = finalPath
|
||||||
|
|
||||||
|
fmt.Println("Decryption successful")
|
||||||
|
}
|
||||||
|
|
||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +286,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalFileDir := filepath.Dir(filePath)
|
||||||
|
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
|
||||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||||
@@ -252,7 +340,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newFilename = newFilename + ".flac"
|
ext := filepath.Ext(filePath)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".flac"
|
||||||
|
}
|
||||||
|
newFilename = newFilename + ext
|
||||||
newFilePath := filepath.Join(outputDir, newFilename)
|
newFilePath := filepath.Join(outputDir, newFilename)
|
||||||
|
|
||||||
if err := os.Rename(filePath, newFilePath); err != nil {
|
if err := os.Rename(filePath, newFilePath); err != nil {
|
||||||
@@ -300,12 +392,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
|
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
||||||
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Metadata embedded successfully")
|
fmt.Println("Metadata embedded successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
|
||||||
|
|
||||||
|
originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
|
||||||
|
if _, err := os.Stat(originalM4aPath); err == nil {
|
||||||
|
if err := os.Remove(originalM4aPath); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Done")
|
fmt.Println("Done")
|
||||||
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
||||||
return filePath, nil
|
return filePath, nil
|
||||||
|
|||||||
+25
-15
@@ -145,10 +145,19 @@ func (q *QobuzDownloader) mapJumoQuality(quality string) int {
|
|||||||
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
formatID := q.mapJumoQuality(quality)
|
formatID := q.mapJumoQuality(quality)
|
||||||
region := "US"
|
region := "US"
|
||||||
url := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
url := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Get(url)
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -163,7 +172,9 @@ func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (strin
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
|
||||||
@@ -173,18 +184,8 @@ func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
|
if result.URL != "" {
|
||||||
return urlVal, nil
|
return result.URL, nil
|
||||||
}
|
|
||||||
|
|
||||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
|
||||||
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
|
|
||||||
return urlVal, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
|
|
||||||
return linkVal, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("URL not found in Jumo response")
|
return "", fmt.Errorf("URL not found in Jumo response")
|
||||||
@@ -216,6 +217,15 @@ func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, qu
|
|||||||
return streamResp.URL, nil
|
return streamResp.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nestedResp struct {
|
||||||
|
Data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
||||||
|
return nestedResp.Data.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("invalid response")
|
return "", fmt.Errorf("invalid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-2
@@ -767,8 +767,57 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
if discNumber == 0 {
|
if discNumber == 0 {
|
||||||
discNumber = 1
|
discNumber = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxDiscFromAlbum := 0
|
||||||
|
totalDiscsFromAlbum := 0
|
||||||
|
|
||||||
|
if len(albumFetchData) > 0 && albumFetchData[0] != nil {
|
||||||
|
albumUnion := getMap(getMap(albumFetchData[0], "data"), "albumUnion")
|
||||||
|
if len(albumUnion) > 0 {
|
||||||
|
discsData := getMap(albumUnion, "discs")
|
||||||
|
if len(discsData) > 0 {
|
||||||
|
totalDiscsFromAlbum = int(getFloat64(discsData, "totalCount"))
|
||||||
|
}
|
||||||
|
|
||||||
|
albumTracks := getMap(albumUnion, "tracks")
|
||||||
|
if len(albumTracks) > 0 {
|
||||||
|
albumTrackItems := getSlice(albumTracks, "items")
|
||||||
|
currentTrackID := getString(trackData, "id")
|
||||||
|
for idx, item := range albumTrackItems {
|
||||||
|
itemMap, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trackItem := getMap(itemMap, "track")
|
||||||
|
if len(trackItem) > 0 {
|
||||||
|
dNum := int(getFloat64(trackItem, "discNumber"))
|
||||||
|
if dNum > maxDiscFromAlbum {
|
||||||
|
maxDiscFromAlbum = dNum
|
||||||
|
}
|
||||||
|
|
||||||
|
trackURI := getString(trackItem, "uri")
|
||||||
|
if strings.Contains(trackURI, currentTrackID) || getString(trackItem, "id") == currentTrackID {
|
||||||
|
if dNum > 0 {
|
||||||
|
discNumber = dNum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackNum := int(getFloat64(trackData, "trackNumber"))
|
||||||
|
itemTrackNum := idx + 1
|
||||||
|
if trackNum == itemTrackNum && dNum > 0 {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
totalDiscs := 1
|
totalDiscs := 1
|
||||||
if discInfo["totalDiscs"] != nil {
|
if totalDiscsFromAlbum > 0 {
|
||||||
|
totalDiscs = totalDiscsFromAlbum
|
||||||
|
} else if maxDiscFromAlbum > 0 {
|
||||||
|
totalDiscs = maxDiscFromAlbum
|
||||||
|
} else if discInfo["totalDiscs"] != nil {
|
||||||
totalDiscs = discInfo["totalDiscs"].(int)
|
totalDiscs = discInfo["totalDiscs"].(int)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,6 +927,11 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
contentRating := getMap(track, "contentRating")
|
contentRating := getMap(track, "contentRating")
|
||||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||||
|
|
||||||
|
discNumber := int(getFloat64(track, "discNumber"))
|
||||||
|
if discNumber == 0 {
|
||||||
|
discNumber = 1
|
||||||
|
}
|
||||||
|
|
||||||
trackInfo := map[string]interface{}{
|
trackInfo := map[string]interface{}{
|
||||||
"id": trackID,
|
"id": trackID,
|
||||||
"name": getString(track, "name"),
|
"name": getString(track, "name"),
|
||||||
@@ -886,6 +940,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
"duration": durationString,
|
"duration": durationString,
|
||||||
"plays": getString(track, "playcount"),
|
"plays": getString(track, "playcount"),
|
||||||
"is_explicit": isExplicit,
|
"is_explicit": isExplicit,
|
||||||
|
"disc_number": discNumber,
|
||||||
}
|
}
|
||||||
tracks = append(tracks, trackInfo)
|
tracks = append(tracks, trackInfo)
|
||||||
}
|
}
|
||||||
@@ -905,6 +960,12 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
albumID = parts[len(parts)-1]
|
albumID = parts[len(parts)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalDiscs := 1
|
||||||
|
discsData := getMap(albumData, "discs")
|
||||||
|
if len(discsData) > 0 {
|
||||||
|
totalDiscs = int(getFloat64(discsData, "totalCount"))
|
||||||
|
}
|
||||||
|
|
||||||
filtered := map[string]interface{}{
|
filtered := map[string]interface{}{
|
||||||
"id": albumID,
|
"id": albumID,
|
||||||
"name": getString(albumData, "name"),
|
"name": getString(albumData, "name"),
|
||||||
@@ -913,6 +974,9 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
"releaseDate": releaseDate,
|
"releaseDate": releaseDate,
|
||||||
"count": len(tracks),
|
"count": len(tracks),
|
||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
|
"discs": map[string]interface{}{
|
||||||
|
"totalCount": totalDiscs,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
@@ -1103,10 +1167,15 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
contentRating := getMap(trackData, "contentRating")
|
contentRating := getMap(trackData, "contentRating")
|
||||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||||
|
|
||||||
|
trackName := getString(trackData, "name")
|
||||||
|
if trackName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
trackInfo := map[string]interface{}{
|
trackInfo := map[string]interface{}{
|
||||||
"id": trackID,
|
"id": trackID,
|
||||||
"cover": trackCover,
|
"cover": trackCover,
|
||||||
"title": getString(trackData, "name"),
|
"title": trackName,
|
||||||
"artist": artistsString,
|
"artist": artistsString,
|
||||||
"artistIds": artistIDs,
|
"artistIds": artistIDs,
|
||||||
"plays": rank,
|
"plays": rank,
|
||||||
@@ -1116,6 +1185,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
"albumId": albumID,
|
"albumId": albumID,
|
||||||
"duration": durationString,
|
"duration": durationString,
|
||||||
"is_explicit": isExplicit,
|
"is_explicit": isExplicit,
|
||||||
|
"disc_number": int(getFloat64(trackData, "discNumber")),
|
||||||
}
|
}
|
||||||
tracks = append(tracks, trackInfo)
|
tracks = append(tracks, trackInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||||
|
if !useAPI || apiBaseURL == "" {
|
||||||
|
|
||||||
|
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
||||||
|
if spotifyType == "" || id == "" {
|
||||||
|
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create API request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
|
||||||
|
switch spotifyType {
|
||||||
|
case "track":
|
||||||
|
var trackResp TrackResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||||
|
}
|
||||||
|
data = trackResp
|
||||||
|
case "album":
|
||||||
|
var albumResp AlbumResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
|
}
|
||||||
|
data = &albumResp
|
||||||
|
case "playlist":
|
||||||
|
var playlistResp PlaylistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
|
}
|
||||||
|
data = playlistResp
|
||||||
|
case "artist":
|
||||||
|
var artistResp ArtistDiscographyPayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
|
}
|
||||||
|
data = &artistResp
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpotifyURLToTypeAndID(url string) (string, string) {
|
||||||
|
|
||||||
|
if strings.HasPrefix(url, "spotify:") {
|
||||||
|
parts := strings.Split(url, ":")
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
return parts[1], parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`spotify\.com/(track|album|playlist|artist)/([a-zA-Z0-9]+)`)
|
||||||
|
matches := re.FindStringSubmatch(url)
|
||||||
|
if len(matches) == 3 {
|
||||||
|
return matches[1], matches[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
+44
-16
@@ -210,6 +210,9 @@ type apiAlbumResponse struct {
|
|||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
ReleaseDate string `json:"releaseDate"`
|
ReleaseDate string `json:"releaseDate"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
Discs struct {
|
||||||
|
TotalCount int `json:"totalCount"`
|
||||||
|
} `json:"discs"`
|
||||||
Tracks []struct {
|
Tracks []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -218,6 +221,7 @@ type apiAlbumResponse struct {
|
|||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
Plays string `json:"plays"`
|
Plays string `json:"plays"`
|
||||||
IsExplicit bool `json:"is_explicit"`
|
IsExplicit bool `json:"is_explicit"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +249,7 @@ type apiPlaylistResponse struct {
|
|||||||
AlbumID string `json:"albumId"`
|
AlbumID string `json:"albumId"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
IsExplicit bool `json:"is_explicit"`
|
IsExplicit bool `json:"is_explicit"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,22 +437,45 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if albumID != "" {
|
if albumID != "" {
|
||||||
albumPayload := map[string]interface{}{
|
|
||||||
"variables": map[string]interface{}{
|
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
|
||||||
"uri": fmt.Sprintf("spotify:album:%s", albumID),
|
if err == nil && albumResponse != nil {
|
||||||
"locale": "",
|
|
||||||
"offset": 0,
|
albumJSON, _ := json.Marshal(albumResponse)
|
||||||
"limit": 1,
|
var albumMap map[string]interface{}
|
||||||
|
json.Unmarshal(albumJSON, &albumMap)
|
||||||
|
|
||||||
|
tracksItems := []interface{}{}
|
||||||
|
if albumMap["tracks"] != nil {
|
||||||
|
if trackList, ok := albumMap["tracks"].([]interface{}); ok {
|
||||||
|
for _, t := range trackList {
|
||||||
|
if trackMap, ok := t.(map[string]interface{}); ok {
|
||||||
|
tracksItems = append(tracksItems, map[string]interface{}{
|
||||||
|
"track": map[string]interface{}{
|
||||||
|
"discNumber": trackMap["disc_number"],
|
||||||
|
"id": trackMap["id"],
|
||||||
|
"uri": fmt.Sprintf("spotify:track:%s", trackMap["id"]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumFetchData = map[string]interface{}{
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"albumUnion": map[string]interface{}{
|
||||||
|
"discs": map[string]interface{}{
|
||||||
|
"totalCount": albumResponse.Discs.TotalCount,
|
||||||
|
},
|
||||||
|
"tracks": map[string]interface{}{
|
||||||
|
"items": tracksItems,
|
||||||
|
"totalCount": albumResponse.Count,
|
||||||
},
|
},
|
||||||
"operationName": "getAlbum",
|
|
||||||
"extensions": map[string]interface{}{
|
|
||||||
"persistedQuery": map[string]interface{}{
|
|
||||||
"version": 1,
|
|
||||||
"sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
albumFetchData, _ = client.Query(albumPayload)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -914,8 +942,8 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
ReleaseDate: raw.ReleaseDate,
|
ReleaseDate: raw.ReleaseDate,
|
||||||
TrackNumber: trackNumber,
|
TrackNumber: trackNumber,
|
||||||
TotalTracks: raw.Count,
|
TotalTracks: raw.Count,
|
||||||
DiscNumber: 1,
|
DiscNumber: item.DiscNumber,
|
||||||
TotalDiscs: 0,
|
TotalDiscs: raw.Discs.TotalCount,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
ISRC: item.ID,
|
ISRC: item.ID,
|
||||||
AlbumID: raw.ID,
|
AlbumID: raw.ID,
|
||||||
@@ -974,7 +1002,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
ReleaseDate: "",
|
ReleaseDate: "",
|
||||||
TrackNumber: 0,
|
TrackNumber: 0,
|
||||||
TotalTracks: 0,
|
TotalTracks: 0,
|
||||||
DiscNumber: 1,
|
DiscNumber: item.DiscNumber,
|
||||||
TotalDiscs: 0,
|
TotalDiscs: 0,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
ISRC: item.ID,
|
ISRC: item.ID,
|
||||||
@@ -1094,7 +1122,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
ReleaseDate: albumData.ReleaseDate,
|
ReleaseDate: albumData.ReleaseDate,
|
||||||
TrackNumber: trackNumber,
|
TrackNumber: trackNumber,
|
||||||
TotalTracks: albumData.Count,
|
TotalTracks: albumData.Count,
|
||||||
DiscNumber: 1,
|
DiscNumber: tr.DiscNumber,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
||||||
ISRC: tr.ID,
|
ISRC: tr.ID,
|
||||||
AlbumID: albumID,
|
AlbumID: albumID,
|
||||||
|
|||||||
+76
-17
@@ -101,6 +101,8 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
|
|||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
fmt.Println("Getting Tidal URL...")
|
fmt.Println("Getting Tidal URL...")
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
@@ -157,7 +159,15 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||||
fmt.Printf("Tidal API URL: %s\n", url)
|
fmt.Printf("Tidal API URL: %s\n", url)
|
||||||
|
|
||||||
resp, err := t.client.Get(url)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("✗ failed to create request: %v\n", err)
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -214,7 +224,14 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|||||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := t.client.Get(url)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
@@ -244,7 +261,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
@@ -253,10 +270,19 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
if directURL != "" {
|
doRequest := func(url string) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if directURL != "" && (strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "") {
|
||||||
fmt.Println("Downloading file...")
|
fmt.Println("Downloading file...")
|
||||||
|
|
||||||
resp, err := client.Get(directURL)
|
resp, err := doRequest(directURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -283,16 +309,48 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tempPath := outputPath + ".m4a.tmp"
|
||||||
|
|
||||||
|
if directURL != "" {
|
||||||
|
fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType)
|
||||||
|
|
||||||
|
resp, err := doRequest(directURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := NewProgressWriter(out)
|
||||||
|
_, err = io.Copy(pw, resp.Body)
|
||||||
|
out.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
|
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
|
||||||
|
|
||||||
tempPath := outputPath + ".m4a.tmp"
|
|
||||||
out, err := os.Create(tempPath)
|
out, err := os.Create(tempPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Print("Downloading init segment... ")
|
fmt.Print("Downloading init segment... ")
|
||||||
resp, err := client.Get(initURL)
|
resp, err := doRequest(initURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(tempPath)
|
||||||
@@ -318,7 +376,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
lastTime := time.Now()
|
lastTime := time.Now()
|
||||||
var lastBytes int64
|
var lastBytes int64
|
||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
resp, err := client.Get(mediaURL)
|
resp, err := doRequest(mediaURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(tempPath)
|
||||||
@@ -359,6 +417,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
|
|
||||||
tempInfo, _ := os.Stat(tempPath)
|
tempInfo, _ := os.Stat(tempPath)
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
|
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Converting to FLAC...")
|
fmt.Println("Converting to FLAC...")
|
||||||
ffmpegPath, err := GetFFmpegPath()
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
@@ -633,10 +692,10 @@ type MPD struct {
|
|||||||
} `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, mimeType 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
manifestStr := string(manifestBytes)
|
manifestStr := string(manifestBytes)
|
||||||
@@ -644,15 +703,15 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
if strings.HasPrefix(strings.TrimSpace(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(btsManifest.URLs) == 0 {
|
if len(btsManifest.URLs) == 0 {
|
||||||
return "", "", nil, fmt.Errorf("no URLs in BTS manifest")
|
return "", "", nil, "", fmt.Errorf("no URLs in BTS manifest")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
|
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
|
||||||
return btsManifest.URLs[0], "", nil, nil
|
return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Manifest: DASH format")
|
fmt.Println("Manifest: DASH format")
|
||||||
@@ -717,7 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
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)
|
||||||
}
|
}
|
||||||
return "", initURL, mediaURLs, nil
|
return "", initURL, mediaURLs, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Using regex fallback for DASH manifest...")
|
fmt.Println("Using regex fallback for DASH manifest...")
|
||||||
@@ -733,7 +792,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
}
|
}
|
||||||
|
|
||||||
if initURL == "" {
|
if initURL == "" {
|
||||||
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
return "", "", nil, "", fmt.Errorf("no initialization URL found in manifest")
|
||||||
}
|
}
|
||||||
|
|
||||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||||
@@ -754,7 +813,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
}
|
}
|
||||||
|
|
||||||
if segmentCount == 0 {
|
if segmentCount == 0 {
|
||||||
return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
return "", "", nil, "", fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
||||||
@@ -764,7 +823,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", initURL, mediaURLs, nil
|
return "", initURL, mediaURLs, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
|
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
|
||||||
|
|||||||
+45
-1
@@ -66,7 +66,12 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
|
|||||||
|
|
||||||
writer.Close()
|
writer.Close()
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", "https://u1112.send.now/cgi-bin/upload.cgi?upload_type=file&utype=anon", body)
|
uploadURL, err := getUploadURL()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get upload server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", uploadURL, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -113,6 +118,45 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
|
|||||||
return fetchDirectImageLink(downloadLink)
|
return fetchDirectImageLink(downloadLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUploadURL() (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", "https://send.now/", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("failed to fetch main page: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
body := string(bodyBytes)
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi\?upload_type=file[^"']*)["']`)
|
||||||
|
matches := re.FindStringSubmatch(body)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return matches[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reFallback := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi)`)
|
||||||
|
matchesFallback := reFallback.FindStringSubmatch(body)
|
||||||
|
if len(matchesFallback) > 1 {
|
||||||
|
return matchesFallback[1] + "?upload_type=file&utype=anon", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("upload URL not found in main page")
|
||||||
|
}
|
||||||
|
|
||||||
func fetchDirectImageLink(url string) (string, error) {
|
func fetchDirectImageLink(url string) (string, error) {
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
629a5f17426ea4202a25837a341483dd
|
9fee02ec6592ede9ade4b36d56bd4d6d
|
||||||
Generated
+71
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.8
|
specifier: ^2.1.8
|
||||||
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-menubar':
|
||||||
|
specifier: ^1.1.16
|
||||||
|
version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@radix-ui/react-progress':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.8
|
specifier: ^1.1.8
|
||||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -467,89 +470,105 @@ packages:
|
|||||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linux-arm64@0.34.5':
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-arm@0.34.5':
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-ppc64@0.34.5':
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-riscv64@0.34.5':
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-s390x@0.34.5':
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.5':
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@img/sharp-wasm32@0.34.5':
|
||||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
@@ -767,6 +786,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-menubar@1.1.16':
|
||||||
|
resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8':
|
'@radix-ui/react-popper@1.2.8':
|
||||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1092,66 +1124,79 @@ packages:
|
|||||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||||
@@ -1221,24 +1266,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||||
@@ -1723,24 +1772,28 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.30.2:
|
lightningcss-linux-arm64-musl@1.30.2:
|
||||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.30.2:
|
lightningcss-linux-x64-gnu@1.30.2:
|
||||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.30.2:
|
lightningcss-linux-x64-musl@1.30.2:
|
||||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.30.2:
|
lightningcss-win32-arm64-msvc@1.30.2:
|
||||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||||
@@ -2658,6 +2711,24 @@ snapshots:
|
|||||||
'@types/react': 19.2.8
|
'@types/react': 19.2.8
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.8
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.0 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||||
|
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_1_219)">
|
||||||
|
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||||
|
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||||
|
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||||
|
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||||
|
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||||
|
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks } from "lucide-react";
|
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart } from "lucide-react";
|
||||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
import XProIcon from "@/assets/x-pro.webp";
|
import XProIcon from "@/assets/x-pro.webp";
|
||||||
@@ -15,6 +15,8 @@ import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
|||||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||||
|
import BmcLogo from "@/assets/bmc-logo.svg";
|
||||||
|
import KofiLogo from "@/assets/kofi_symbol.svg";
|
||||||
import { langColors } from "@/assets/github-lang-colors";
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { DragDropMedia } from "./DragDropTextarea";
|
import { DragDropMedia } from "./DragDropTextarea";
|
||||||
@@ -24,7 +26,7 @@ interface AboutPageProps {
|
|||||||
export function AboutPage({ version }: AboutPageProps) {
|
export function AboutPage({ version }: AboutPageProps) {
|
||||||
const [os, setOs] = useState("Unknown");
|
const [os, setOs] = useState("Unknown");
|
||||||
const [location, setLocation] = useState("Unknown");
|
const [location, setLocation] = useState("Unknown");
|
||||||
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects">("bug_report");
|
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
|
||||||
const [bugType, setBugType] = useState("Track");
|
const [bugType, setBugType] = useState("Track");
|
||||||
const [problem, setProblem] = useState("");
|
const [problem, setProblem] = useState("");
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
@@ -266,6 +268,10 @@ ${contextContent}`;
|
|||||||
<Blocks className="h-4 w-4"/>
|
<Blocks className="h-4 w-4"/>
|
||||||
Other Projects
|
Other Projects
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
||||||
|
<Heart className="h-4 w-4"/>
|
||||||
|
Support Us
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
|
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
|
||||||
@@ -433,6 +439,28 @@ ${contextContent}`;
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
|
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-2xl font-bold tracking-tight">Support Our Work</h3>
|
||||||
|
<p className="text-muted-foreground max-w-[500px]">
|
||||||
|
If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4 w-full max-w-lg">
|
||||||
|
<Button size="lg" className="h-16 text-lg font-semibold text-white gap-3 group" style={{ backgroundColor: "#72a4f2" }} onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
|
||||||
|
Support me on Ko-fi
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="lg" className="h-16 text-lg font-semibold text-black gap-3 group" style={{ backgroundColor: "#ffdd00" }} onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")}>
|
||||||
|
<img src={BmcLogo} className="h-6 w-6 transition-transform group-hover:scale-110" alt="Buy Me a Coffee"/>
|
||||||
|
Buy Me a Coffee
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export function AudioConverterPage() {
|
|||||||
codec: outputFormat === "m4a" ? m4aCodec : "",
|
codec: outputFormat === "m4a" ? m4aCodec : "",
|
||||||
});
|
});
|
||||||
setFiles((prev) => prev.map((f) => {
|
setFiles((prev) => prev.map((f) => {
|
||||||
const result = results.find((r) => r.input_file === f.path);
|
const result = results.find((r) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
|
||||||
if (result) {
|
if (result) {
|
||||||
return {
|
return {
|
||||||
...f,
|
...f,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Trash2, Copy, Check } from "lucide-react";
|
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { logger, type LogEntry } from "@/lib/logger";
|
import { logger, type LogEntry } from "@/lib/logger";
|
||||||
|
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
const levelColors: Record<string, string> = {
|
const levelColors: Record<string, string> = {
|
||||||
info: "text-blue-500",
|
info: "text-blue-500",
|
||||||
success: "text-green-500",
|
success: "text-green-500",
|
||||||
@@ -51,10 +53,29 @@ export function DebugLoggerPage() {
|
|||||||
console.error("Failed to copy logs:", err);
|
console.error("Failed to copy logs:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleExportFailed = async () => {
|
||||||
|
try {
|
||||||
|
const message = await ExportFailedDownloads();
|
||||||
|
if (message.startsWith("Successfully")) {
|
||||||
|
toast.success(message);
|
||||||
|
}
|
||||||
|
else if (message !== "Export cancelled") {
|
||||||
|
toast.info(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to export:", error);
|
||||||
|
toast.error(`Failed to export: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed}>
|
||||||
|
<FileDown className="h-4 w-4"/>
|
||||||
|
Export Failed
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
|
||||||
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||||
Copy
|
Copy
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
|
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer, FileDown } from "lucide-react";
|
||||||
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, ClearAllDownloads } from "../../wailsjs/go/main/App";
|
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads, ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
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 {
|
||||||
@@ -59,6 +59,21 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
console.error("Failed to reset queue:", error);
|
console.error("Failed to reset queue:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleExportFailed = async () => {
|
||||||
|
try {
|
||||||
|
const message = await ExportFailedDownloads();
|
||||||
|
if (message.startsWith("Successfully")) {
|
||||||
|
toast.success(message);
|
||||||
|
}
|
||||||
|
else if (message !== "Export cancelled") {
|
||||||
|
toast.info(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to export:", error);
|
||||||
|
toast.error(`Failed to export: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "downloading":
|
case "downloading":
|
||||||
@@ -105,6 +120,15 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
return `${seconds}s`;
|
return `${seconds}s`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
|
const toggleFilter = (status: string) => {
|
||||||
|
setFilterStatus(prev => prev === status ? "all" : status);
|
||||||
|
};
|
||||||
|
const filteredQueue = queueInfo.queue.filter((item: any) => {
|
||||||
|
if (filterStatus === "all")
|
||||||
|
return true;
|
||||||
|
return item.status === filterStatus;
|
||||||
|
});
|
||||||
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">
|
||||||
@@ -115,6 +139,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
<Trash2 className="h-3 w-3"/>
|
<Trash2 className="h-3 w-3"/>
|
||||||
Clear History
|
Clear History
|
||||||
</Button>)}
|
</Button>)}
|
||||||
|
{queueInfo.failed_count > 0 && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleExportFailed}>
|
||||||
|
<FileDown className="h-3 w-3"/>
|
||||||
|
Export Failures
|
||||||
|
</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>
|
||||||
@@ -123,22 +151,22 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
|
|
||||||
|
|
||||||
<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 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'queued' ? 'bg-secondary px-2 py-0.5 rounded-md ring-1 ring-border' : ''}`} onClick={() => toggleFilter('queued')}>
|
||||||
<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>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'completed' ? 'bg-green-500/10 px-2 py-0.5 rounded-md ring-1 ring-green-500/20' : ''}`} onClick={() => toggleFilter('completed')}>
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
||||||
<span className="text-muted-foreground">Completed:</span>
|
<span className="text-muted-foreground">Completed:</span>
|
||||||
<span className="font-semibold">{queueInfo.completed_count}</span>
|
<span className="font-semibold">{queueInfo.completed_count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'skipped' ? 'bg-yellow-500/10 px-2 py-0.5 rounded-md ring-1 ring-yellow-500/20' : ''}`} onClick={() => toggleFilter('skipped')}>
|
||||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
||||||
<span className="text-muted-foreground">Skipped:</span>
|
<span className="text-muted-foreground">Skipped:</span>
|
||||||
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'failed' ? 'bg-red-500/10 px-2 py-0.5 rounded-md ring-1 ring-red-500/20' : ''}`} onClick={() => toggleFilter('failed')}>
|
||||||
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
||||||
<span className="text-muted-foreground">Failed:</span>
|
<span className="text-muted-foreground">Failed:</span>
|
||||||
<span className="font-semibold">{queueInfo.failed_count}</span>
|
<span className="font-semibold">{queueInfo.failed_count}</span>
|
||||||
@@ -180,7 +208,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
{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"/>
|
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||||
<p>No downloads in queue</p>
|
<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>) : filteredQueue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>No downloads with status "{filterStatus}"</p>
|
||||||
|
<Button variant="link" onClick={() => setFilterStatus("all")}>Clear filter</Button>
|
||||||
|
</div>) : (filteredQueue.map((item: any) => (<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="flex items-start gap-3">
|
||||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||||
|
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setFilteredDownloadHistory(result);
|
setFilteredDownloadHistory(result);
|
||||||
setDownloadCurrentPage(1);
|
|
||||||
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
|
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
|
||||||
|
useEffect(() => {
|
||||||
|
setDownloadCurrentPage(1);
|
||||||
|
}, [downloadSearchQuery, downloadSortBy]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let result = [...fetchHistory];
|
let result = [...fetchHistory];
|
||||||
if (activeFetchTab !== "all") {
|
if (activeFetchTab !== "all") {
|
||||||
@@ -144,8 +146,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
}
|
}
|
||||||
result.sort((a, b) => b.timestamp - a.timestamp);
|
result.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
setFilteredFetchHistory(result);
|
setFilteredFetchHistory(result);
|
||||||
setFetchCurrentPage(1);
|
|
||||||
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
|
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
|
||||||
|
useEffect(() => {
|
||||||
|
setFetchCurrentPage(1);
|
||||||
|
}, [fetchSearchQuery, activeFetchTab]);
|
||||||
const handlePreview = async (id: string, spotifyId: string) => {
|
const handlePreview = async (id: string, spotifyId: string) => {
|
||||||
if (playingPreviewId === id) {
|
if (playingPreviewId === id) {
|
||||||
audioRef.current?.pause();
|
audioRef.current?.pause();
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, Settings, FolderCog, } 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 { 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";
|
||||||
@@ -17,34 +17,34 @@ const TidalIcon = ({ className }: {
|
|||||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "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 = ({ className }: {
|
const QobuzIcon = ({ className }: {
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "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 = ({ className }: {
|
const AmazonIcon = ({ className }: {
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "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 hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||||
const resetToSaved = useCallback(() => {
|
const resetToSaved = useCallback(() => {
|
||||||
const freshSavedSettings = getSettings();
|
const freshSavedSettings = getSettings();
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setTempSettings(freshSavedSettings);
|
setTempSettings(freshSavedSettings);
|
||||||
setIsDark(document.documentElement.classList.contains('dark'));
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,7 +73,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
applyTheme(tempSettings.theme);
|
applyTheme(tempSettings.theme);
|
||||||
applyFont(tempSettings.fontFamily);
|
applyFont(tempSettings.fontFamily);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsDark(document.documentElement.classList.contains('dark'));
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,17 +124,43 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||||
};
|
};
|
||||||
return (<div className="space-y-4">
|
const [activeTab, setActiveTab] = useState<"general" | "files">("general");
|
||||||
|
return (<div className="space-y-4 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
|
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
|
||||||
|
<Settings className="h-4 w-4"/>
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||||
|
<FolderCog className="h-4 w-4"/>
|
||||||
|
File Management
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="flex-1 overflow-y-auto pt-4">
|
||||||
|
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-4">
|
||||||
|
<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
|
||||||
@@ -142,8 +168,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
|
||||||
<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">
|
||||||
@@ -157,8 +182,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="theme">Accent</Label>
|
<Label htmlFor="theme">Accent</Label>
|
||||||
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
||||||
<SelectTrigger id="theme">
|
<SelectTrigger id="theme">
|
||||||
@@ -168,7 +192,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="w-3 h-3 rounded-full border border-border" style={{
|
<span className="w-3 h-3 rounded-full border border-border" style={{
|
||||||
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
backgroundColor: isDark
|
||||||
|
? theme.cssVars.dark.primary
|
||||||
|
: theme.cssVars.light.primary,
|
||||||
}}/>
|
}}/>
|
||||||
{theme.label}
|
{theme.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -177,8 +203,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="font">Font</Label>
|
<Label htmlFor="font">Font</Label>
|
||||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||||
<SelectTrigger id="font">
|
<SelectTrigger id="font">
|
||||||
@@ -186,84 +211,165 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||||
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
<span style={{ fontFamily: font.fontFamily }}>
|
||||||
|
{font.label}
|
||||||
|
</span>
|
||||||
</SelectItem>))}
|
</SelectItem>))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<div className="flex items-center gap-3">
|
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
...prev,
|
||||||
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
|
sfxEnabled: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm font-normal">
|
||||||
|
Sound Effects
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="downloader">Source</Label>
|
||||||
<div className="space-y-1">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({
|
||||||
<div className="flex gap-2">
|
...prev,
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
downloader: value,
|
||||||
|
}))}>
|
||||||
<SelectTrigger id="downloader" className="h-9 w-fit">
|
<SelectTrigger id="downloader" className="h-9 w-fit">
|
||||||
<SelectValue placeholder="Select a source"/>
|
<SelectValue placeholder="Select a source"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
<SelectItem value="tidal">
|
<SelectItem value="tidal">
|
||||||
<span className="flex items-center"><TidalIcon />Tidal</span>
|
<span className="flex items-center">
|
||||||
|
<TidalIcon />
|
||||||
|
Tidal
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz">
|
<SelectItem value="qobuz">
|
||||||
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
<span className="flex items-center">
|
||||||
|
<QobuzIcon />
|
||||||
|
Qobuz
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon">
|
<SelectItem value="amazon">
|
||||||
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
<span className="flex items-center">
|
||||||
|
<AmazonIcon />
|
||||||
|
Amazon Music
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{tempSettings.downloader === "auto" && (<>
|
{tempSettings.downloader === "auto" && (<>
|
||||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({ ...prev, autoOrder: value }))}>
|
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
|
||||||
<SelectTrigger className="h-9 w-fit">
|
...prev,
|
||||||
|
autoOrder: value,
|
||||||
|
}))}>
|
||||||
|
<SelectTrigger className="h-9 w-fit min-w-[140px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="tidal-qobuz">
|
<SelectItem value="tidal-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tidal-amazon">
|
<SelectItem value="tidal-amazon">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-tidal">
|
<SelectItem value="qobuz-tidal">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-amazon">
|
<SelectItem value="qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-tidal">
|
<SelectItem value="amazon-tidal">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-qobuz">
|
<SelectItem value="amazon-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tidal-qobuz-amazon">
|
<SelectItem value="tidal-qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="tidal-amazon-qobuz">
|
<SelectItem value="tidal-amazon-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-tidal-amazon">
|
<SelectItem value="qobuz-tidal-amazon">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-amazon-tidal">
|
<SelectItem value="qobuz-amazon-tidal">
|
||||||
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-tidal-qobuz">
|
<SelectItem value="amazon-tidal-qobuz">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="amazon-qobuz-tidal">
|
<SelectItem value="amazon-qobuz-tidal">
|
||||||
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -285,7 +391,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||||
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
|
<SelectItem value="HI_RES_LOSSLESS">
|
||||||
|
24-bit/48kHz
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
@@ -300,37 +408,56 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
||||||
16-bit/44.1kHz / 24-bit/48kHz
|
16-bit - 24-bit/44.1kHz - 192kHz
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{((tempSettings.downloader === "tidal" &&
|
||||||
|
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
|
(tempSettings.downloader === "qobuz" &&
|
||||||
|
tempSettings.qobuzQuality === "7") ||
|
||||||
|
(tempSettings.downloader === "auto" &&
|
||||||
|
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
allowFallback: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
|
||||||
|
Allow Quality Fallback (16-bit)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6"/>
|
||||||
|
|
||||||
{((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
<div className="space-y-4">
|
||||||
(tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "7") ||
|
<div className="flex items-center gap-3">
|
||||||
(tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pl-1">
|
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
<div className="flex items-center space-x-2">
|
...prev,
|
||||||
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/>
|
embedLyrics: checked,
|
||||||
<Label htmlFor="allow-fallback" className="text-sm font-normal">Allow Quality Fallback (16-bit)</Label>
|
}))}/>
|
||||||
|
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Lyrics
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedMaxQualityCover: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Max Quality Cover
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="flex items-center gap-6">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-2">
|
||||||
<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 className="border-t"/>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<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>
|
||||||
@@ -338,37 +465,83 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
<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>
|
</div>
|
||||||
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
|
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
|
Preview:{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{tempSettings.folderTemplate
|
||||||
|
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
||||||
|
.replace(/\{album\}/g, "Black Panther")
|
||||||
|
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||||
|
.replace(/\{year\}/g, "2018")}
|
||||||
|
/
|
||||||
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t"/>
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="create-playlist-folder" checked={tempSettings.createPlaylistFolder} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
createPlaylistFolder: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="create-playlist-folder" className="text-sm cursor-pointer font-normal">
|
||||||
|
Playlist Folder
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="create-m3u8-file" checked={tempSettings.createM3u8File} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
createM3u8File: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="create-m3u8-file" className="text-sm cursor-pointer font-normal">
|
||||||
|
Create M3U8 Playlist File
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
useFirstArtistOnly: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
||||||
|
Use First Artist Only
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -376,63 +549,71 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
<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>
|
</div>
|
||||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
Preview:{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{tempSettings.filenameTemplate
|
||||||
|
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
||||||
|
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||||
|
.replace(/\{title\}/g, "All The Stars")
|
||||||
|
.replace(/\{track\}/g, "01")
|
||||||
|
.replace(/\{disc\}/g, "1")
|
||||||
|
.replace(/\{year\}/g, "2018")}
|
||||||
|
.flac
|
||||||
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-between pt-3 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>
|
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Reset to Default?</DialogTitle>
|
<DialogTitle>Reset to Default?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will reset all settings to their default values. Your custom configurations will be lost.
|
This will reset all settings to their default values. Your custom
|
||||||
|
configurations will be lost.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button onClick={handleReset}>Reset</Button>
|
<Button onClick={handleReset}>Reset</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import BmcLogo from "@/assets/bmc-logo-side.svg";
|
||||||
|
import BmcLogoWhite from "@/assets/bmc-logo-side-white.svg";
|
||||||
|
import KofiLogo from "@/assets/kofi_symbol.svg";
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
@@ -109,16 +112,26 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<p>About</p>
|
<p>About</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={0}>
|
<div className="relative group">
|
||||||
<TooltipTrigger asChild>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
|
||||||
<CoffeeIcon size={20} loop={true}/>
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>Every coffee helps me keep going</p>
|
<div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
<div className="absolute left-10 bottom-0 mb-0 ml-3 hidden group-hover:flex flex-col gap-1 p-1 bg-popover border border-border rounded-md shadow-md z-50 w-max animate-in fade-in zoom-in-95 duration-200 origin-bottom-left">
|
||||||
|
<button onClick={() => openExternal("https://ko-fi.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
||||||
|
<img src={KofiLogo} className="h-4 w-4" alt="Ko-fi"/>
|
||||||
|
Support me on Ko-fi
|
||||||
|
</button>
|
||||||
|
<button onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
||||||
|
<img src={BmcLogo} className="h-4 w-4 dark:hidden" alt="BMC"/>
|
||||||
|
<img src={BmcLogoWhite} className="h-4 w-4 hidden dark:block" alt="BMC"/>
|
||||||
|
Buy Me a Coffee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { X, Minus, Maximize } from "lucide-react";
|
import { X, Minus, Maximize, Settings, Info } from "lucide-react";
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
|
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { getSettings, updateSettings } from "@/lib/settings";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
|
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings) {
|
||||||
|
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const handleSpotFetchAPIToggle = () => {
|
||||||
|
const newValue = !useSpotFetchAPI;
|
||||||
|
setUseSpotFetchAPI(newValue);
|
||||||
|
updateSettings({ useSpotFetchAPI: newValue });
|
||||||
|
};
|
||||||
const handleMinimize = () => {
|
const handleMinimize = () => {
|
||||||
WindowMinimise();
|
WindowMinimise();
|
||||||
};
|
};
|
||||||
@@ -15,7 +31,35 @@ export function TitleBar() {
|
|||||||
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
|
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
|
||||||
|
|
||||||
|
|
||||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5 items-center">
|
||||||
|
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
||||||
|
<MenubarMenu>
|
||||||
|
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||||
|
<Settings className="w-3.5 h-3.5"/>
|
||||||
|
</MenubarTrigger>
|
||||||
|
<MenubarContent align="end" className="min-w-[200px]">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
|
<MenubarLabel className="p-0">SpotFetch API</MenubarLabel>
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-3.5 h-3.5 cursor-help text-muted-foreground"/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="max-w-xs">
|
||||||
|
<p className="font-semibold mb-2">Spotify Blocked Countries:</p>
|
||||||
|
<p className="text-xs">Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<MenubarSeparator />
|
||||||
|
<MenubarItem onClick={handleSpotFetchAPIToggle} className="justify-between">
|
||||||
|
<span>Use SpotFetch API</span>
|
||||||
|
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarContent>
|
||||||
|
</MenubarMenu>
|
||||||
|
</Menubar>
|
||||||
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
|
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
|
||||||
<Minus className="w-3.5 h-3.5"/>
|
<Minus className="w-3.5 h-3.5"/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
const Menubar = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Root>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>>(({ className, ...props }, ref) => (<MenubarPrimitive.Root ref={ref} className={cn("flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", className)} {...props}/>));
|
||||||
|
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||||
|
const MenubarMenu = MenubarPrimitive.Menu;
|
||||||
|
const MenubarGroup = MenubarPrimitive.Group;
|
||||||
|
const MenubarPortal = MenubarPrimitive.Portal;
|
||||||
|
const MenubarSub = MenubarPrimitive.Sub;
|
||||||
|
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||||
|
const MenubarTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>>(({ className, ...props }, ref) => (<MenubarPrimitive.Trigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className)} {...props}/>));
|
||||||
|
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||||
|
const MenubarSubTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubTrigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}>(({ className, inset, children, ...props }, ref) => (<MenubarPrimitive.SubTrigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", inset && "pl-8", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4"/>
|
||||||
|
</MenubarPrimitive.SubTrigger>));
|
||||||
|
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||||
|
const MenubarSubContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubContent>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>>(({ className, ...props }, ref) => (<MenubarPrimitive.SubContent ref={ref} className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className)} {...props}/>));
|
||||||
|
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||||
|
const MenubarContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Content>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (<MenubarPrimitive.Portal>
|
||||||
|
<MenubarPrimitive.Content ref={ref} align={align} alignOffset={alignOffset} sideOffset={sideOffset} className={cn("z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in slide-in-from-top-1", className)} {...props}/>
|
||||||
|
</MenubarPrimitive.Portal>));
|
||||||
|
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||||
|
const MenubarItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Item>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className)} {...props}/>));
|
||||||
|
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||||
|
const MenubarCheckboxItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>>(({ className, children, checked, ...props }, ref) => (<MenubarPrimitive.CheckboxItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} checked={checked} {...props}>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4"/>
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>));
|
||||||
|
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||||
|
const MenubarRadioItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.RadioItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>>(({ className, children, ...props }, ref) => (<MenubarPrimitive.RadioItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props}>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current"/>
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>));
|
||||||
|
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||||
|
const MenubarLabel = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Label>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} {...props}/>));
|
||||||
|
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||||
|
const MenubarSeparator = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>>(({ className, ...props }, ref) => (<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props}/>));
|
||||||
|
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||||
|
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||||
|
};
|
||||||
|
MenubarShortcut.displayname = "MenubarShortcut";
|
||||||
|
export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarSeparator, MenubarLabel, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarPortal, MenubarSubContent, MenubarSubTrigger, MenubarSub, MenubarGroup, MenubarShortcut, };
|
||||||
@@ -5,6 +5,13 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
|||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
function getFirstArtist(artistString: string): string {
|
||||||
|
if (!artistString)
|
||||||
|
return artistString;
|
||||||
|
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
|
||||||
|
const parts = artistString.split(delimiters);
|
||||||
|
return parts[0].trim();
|
||||||
|
}
|
||||||
interface CheckFileExistenceRequest {
|
interface CheckFileExistenceRequest {
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
track_name: string;
|
track_name: string;
|
||||||
@@ -28,8 +35,9 @@ interface FileExistenceResult {
|
|||||||
track_name?: string;
|
track_name?: string;
|
||||||
artist_name?: string;
|
artist_name?: string;
|
||||||
}
|
}
|
||||||
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
|
const CheckFilesExistence = (outputDir: string, rootDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, rootDir, tracks);
|
||||||
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
|
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
|
||||||
|
const CreateM3U8File = (playlistName: string, outputDir: string, filePaths: string[]): Promise<void> => (window as any)["go"]["main"]["App"]["CreateM3U8File"](playlistName, outputDir, filePaths);
|
||||||
export function useDownload(region: string) {
|
export function useDownload(region: string) {
|
||||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
@@ -74,10 +82,16 @@ export function useDownload(region: string) {
|
|||||||
if (hasSubfolder) {
|
if (hasSubfolder) {
|
||||||
useAlbumTrackNumber = true;
|
useAlbumTrackNumber = true;
|
||||||
}
|
}
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && artistName
|
||||||
|
? getFirstArtist(artistName)
|
||||||
|
: artistName;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
|
||||||
|
? getFirstArtist(albumArtist)
|
||||||
|
: albumArtist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: trackNumberForTemplate,
|
track: trackNumberForTemplate,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
@@ -85,7 +99,7 @@ export function useDownload(region: string) {
|
|||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && !useAlbumSubfolder) {
|
if (settings.createPlaylistFolder && 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) {
|
||||||
@@ -105,9 +119,9 @@ export function useDownload(region: string) {
|
|||||||
const checkRequest: CheckFileExistenceRequest = {
|
const checkRequest: CheckFileExistenceRequest = {
|
||||||
spotify_id: spotifyId || isrc,
|
spotify_id: spotifyId || isrc,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist || "",
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
track_number: finalTrackNumber || spotifyTrackNumber || 0,
|
track_number: finalTrackNumber || spotifyTrackNumber || 0,
|
||||||
disc_number: spotifyDiscNumber || 0,
|
disc_number: spotifyDiscNumber || 0,
|
||||||
@@ -117,7 +131,7 @@ export function useDownload(region: string) {
|
|||||||
include_track_number: settings.trackNumber || false,
|
include_track_number: settings.trackNumber || false,
|
||||||
audio_format: serviceForCheck,
|
audio_format: serviceForCheck,
|
||||||
};
|
};
|
||||||
const existenceResults = await CheckFilesExistence(outputDir, [checkRequest]);
|
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, [checkRequest]);
|
||||||
if (existenceResults.length > 0 && existenceResults[0].exists) {
|
if (existenceResults.length > 0 && existenceResults[0].exists) {
|
||||||
fileExists = true;
|
fileExists = true;
|
||||||
return {
|
return {
|
||||||
@@ -135,7 +149,7 @@ export function useDownload(region: string) {
|
|||||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||||
let itemID: string | undefined;
|
let itemID: string | undefined;
|
||||||
if (!fileExists) {
|
if (!fileExists) {
|
||||||
itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
|
itemID = await AddToDownloadQueue(isrc, trackName || "", displayArtist || "", albumName || "");
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
@@ -375,7 +389,7 @@ export function useDownload(region: string) {
|
|||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (folderName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -628,7 +642,7 @@ export function useDownload(region: string) {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
if (folderName && (!isAlbum || !useAlbumTag)) {
|
if (settings.createPlaylistFolder && 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
|
||||||
@@ -654,13 +668,15 @@ export function useDownload(region: string) {
|
|||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
|
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
|
||||||
const existingSpotifyIDs = new Set<string>();
|
const existingSpotifyIDs = new Set<string>();
|
||||||
const existingFilePaths = new Map<string, string>();
|
const existingFilePaths = new Map<string, string>();
|
||||||
|
const finalFilePaths = new Map<string, string>();
|
||||||
for (const result of existenceResults) {
|
for (const result of existenceResults) {
|
||||||
if (result.exists) {
|
if (result.exists) {
|
||||||
existingSpotifyIDs.add(result.spotify_id);
|
existingSpotifyIDs.add(result.spotify_id);
|
||||||
existingFilePaths.set(result.spotify_id, result.file_path || "");
|
existingFilePaths.set(result.spotify_id, result.file_path || "");
|
||||||
|
finalFilePaths.set(result.spotify_id, result.file_path || "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
||||||
@@ -711,6 +727,10 @@ export function useDownload(region: string) {
|
|||||||
successCount++;
|
successCount++;
|
||||||
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
||||||
}
|
}
|
||||||
|
if (response.file) {
|
||||||
|
finalFilePaths.set(isrc, response.file);
|
||||||
|
finalFilePaths.set(track.spotify_id || isrc, response.file);
|
||||||
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
setFailedTracks((prev) => {
|
setFailedTracks((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -743,6 +763,20 @@ export function useDownload(region: string) {
|
|||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||||
await CancelAllQueuedItems();
|
await CancelAllQueuedItems();
|
||||||
|
if (settings.createM3u8File && folderName) {
|
||||||
|
const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || t.isrc) || "").filter((p) => p !== "");
|
||||||
|
if (paths.length > 0) {
|
||||||
|
try {
|
||||||
|
logger.info(`creating m3u8 playlist: ${folderName}`);
|
||||||
|
await CreateM3U8File(folderName, outputDir, paths);
|
||||||
|
toast.success("M3U8 playlist created");
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`failed to create m3u8 playlist: ${err}`);
|
||||||
|
toast.error(`Failed to create M3U8 playlist: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||||
if (errorCount === 0 && skippedCount === 0) {
|
if (errorCount === 0 && skippedCount === 0) {
|
||||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
@@ -777,7 +811,7 @@ export function useDownload(region: string) {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
if (folderName && (!isAlbum || !useAlbumTag)) {
|
if (settings.createPlaylistFolder && 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...`);
|
||||||
@@ -800,13 +834,16 @@ export function useDownload(region: string) {
|
|||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
|
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
|
||||||
|
const finalFilePaths: string[] = new Array(tracksWithIsrc.length).fill("");
|
||||||
const existingSpotifyIDs = new Set<string>();
|
const existingSpotifyIDs = new Set<string>();
|
||||||
const existingFilePaths = new Map<string, string>();
|
const existingFilePaths = new Map<string, string>();
|
||||||
for (const result of existenceResults) {
|
for (let i = 0; i < existenceResults.length; i++) {
|
||||||
|
const result = existenceResults[i];
|
||||||
if (result.exists) {
|
if (result.exists) {
|
||||||
existingSpotifyIDs.add(result.spotify_id);
|
existingSpotifyIDs.add(result.spotify_id);
|
||||||
existingFilePaths.set(result.spotify_id, result.file_path || "");
|
existingFilePaths.set(result.spotify_id, result.file_path || "");
|
||||||
|
finalFilePaths[i] = result.file_path || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
||||||
@@ -861,6 +898,9 @@ export function useDownload(region: string) {
|
|||||||
newSet.delete(track.isrc);
|
newSet.delete(track.isrc);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
if (response.file) {
|
||||||
|
finalFilePaths[originalIndex] = response.file;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
@@ -885,6 +925,17 @@ export function useDownload(region: string) {
|
|||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
||||||
await CancelQueued();
|
await CancelQueued();
|
||||||
|
if (settings.createM3u8File && folderName) {
|
||||||
|
try {
|
||||||
|
logger.info(`creating m3u8 playlist: ${folderName}`);
|
||||||
|
await CreateM3U8File(folderName, outputDir, finalFilePaths.filter(p => p !== ""));
|
||||||
|
toast.success("M3U8 playlist created");
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`failed to create m3u8 playlist: ${err}`);
|
||||||
|
toast.error(`Failed to create M3U8 playlist: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||||
if (errorCount === 0 && skippedCount === 0) {
|
if (errorCount === 0 && skippedCount === 0) {
|
||||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export interface Settings {
|
|||||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
|
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
|
||||||
autoQuality: "16" | "24";
|
autoQuality: "16" | "24";
|
||||||
allowFallback: boolean;
|
allowFallback: boolean;
|
||||||
|
useSpotFetchAPI: boolean;
|
||||||
|
spotFetchAPIUrl: string;
|
||||||
|
createPlaylistFolder: boolean;
|
||||||
|
createM3u8File: boolean;
|
||||||
|
useFirstArtistOnly: boolean;
|
||||||
}
|
}
|
||||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -101,7 +106,12 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
amazonQuality: "original",
|
amazonQuality: "original",
|
||||||
autoOrder: "tidal-qobuz-amazon",
|
autoOrder: "tidal-qobuz-amazon",
|
||||||
autoQuality: "16",
|
autoQuality: "16",
|
||||||
allowFallback: true
|
allowFallback: true,
|
||||||
|
useSpotFetchAPI: false,
|
||||||
|
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
|
||||||
|
createPlaylistFolder: true,
|
||||||
|
createM3u8File: false,
|
||||||
|
useFirstArtistOnly: false
|
||||||
};
|
};
|
||||||
export const FONT_OPTIONS: {
|
export const FONT_OPTIONS: {
|
||||||
value: FontFamily;
|
value: FontFamily;
|
||||||
@@ -290,6 +300,15 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
if (!('allowFallback' in parsed)) {
|
if (!('allowFallback' in parsed)) {
|
||||||
parsed.allowFallback = true;
|
parsed.allowFallback = true;
|
||||||
}
|
}
|
||||||
|
if (!('createPlaylistFolder' in parsed)) {
|
||||||
|
parsed.createPlaylistFolder = true;
|
||||||
|
}
|
||||||
|
if (!('createM3u8File' in parsed)) {
|
||||||
|
parsed.createM3u8File = false;
|
||||||
|
}
|
||||||
|
if (!('useFirstArtistOnly' in parsed)) {
|
||||||
|
parsed.useFirstArtistOnly = false;
|
||||||
|
}
|
||||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
return cachedSettings!;
|
return cachedSettings!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
export function sanitizePath(input: string, os: string): string {
|
export function sanitizePath(input: string, os: string): string {
|
||||||
let sanitized = input.trim();
|
const sanitized = input.trim();
|
||||||
if (os === "Windows") {
|
if (os === "Windows") {
|
||||||
return sanitized.replace(/[<>:"/\\|?*]/g, "_");
|
return sanitized.replace(/[<>:"/\\|?*]/g, "_");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.0.7",
|
"productVersion": "7.0.8",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user