This commit is contained in:
afkarxyz
2026-02-10 21:18:05 +07:00
parent 36a77ad8d1
commit df56049db2
30 changed files with 1689 additions and 488 deletions
+1
View File
@@ -1,2 +1,3 @@
github: afkarxyz
ko_fi: afkarxyz
buy_me_a_coffee: afkarxyz
+2 -3
View File
@@ -2,8 +2,6 @@
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
<!-- ![Maintenance](https://maintenance.afkarxyz.fun?v=3) -->
![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<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.
Your support helps keep development going._
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz)
[![Ko-fi](https://img.shields.io/badge/Support%20me%20on%20Ko--fi-72a5f2?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/afkarxyz)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/afkarxyz)
## Disclaimer
+201 -1
View File
@@ -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)))
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)))
if err != nil {
return "", fmt.Errorf("failed to fetch metadata: %v", err)
@@ -592,6 +613,76 @@ func (a *App) 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() {
panic("quit")
@@ -1091,12 +1182,15 @@ type CheckFileExistenceResult struct {
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 {
return []CheckFileExistenceResult{}
}
outputDir = backend.NormalizePath(outputDir)
if rootDir != "" {
rootDir = backend.NormalizePath(rootDir)
}
defaultFilenameFormat := "title-artist"
@@ -1107,6 +1201,30 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
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 {
go func(idx int, t CheckFileExistenceRequest) {
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 {
res.Exists = true
res.FilePath = expectedPath
} else {
res.FilePath = expectedFilename
}
resultsChan <- result{index: idx, result: res}
@@ -1170,9 +1291,39 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
}
results := make([]CheckFileExistenceResult, len(tracks))
missingIndices := []int{}
for i := 0; i < len(tracks); i++ {
r := <-resultsChan
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
@@ -1245,3 +1396,52 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
func (a *App) GetOSInfo() (string, error) {
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
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
@@ -25,13 +26,9 @@ type SongLinkResponse struct {
} `json:"linksByPlatform"`
}
type AfkarXYZResponse struct {
Success bool `json:"success"`
Data struct {
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
} `json:"data"`
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
@@ -55,6 +52,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
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")
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) {
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)
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")
fmt.Printf("Fetching from AfkarXYZ...\n")
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req)
if err != nil {
return "", err
@@ -122,27 +128,25 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
defer resp.Body.Close()
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)
var apiResp AfkarXYZResponse
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var apiResp AmazonStreamResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if !apiResp.Success || apiResp.Data.DirectLink == "" {
return "", fmt.Errorf("AfkarXYZ failed or no link found")
if apiResp.StreamURL == "" {
return "", fmt.Errorf("no stream URL found in response")
}
downloadURL := apiResp.Data.DirectLink
fileName := apiResp.Data.FileName
if fileName == "" {
fileName = "track.flac"
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
downloadURL := apiResp.StreamURL
fileName := fmt.Sprintf("%s.m4a", asin)
filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath)
@@ -152,6 +156,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
defer out.Close()
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)
if err != nil {
@@ -159,7 +164,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
}
defer dlResp.Body.Close()
fmt.Printf("Downloading from AfkarXYZ: %s\n", fileName)
fmt.Printf("Downloading track: %s\n", fileName)
pw := NewProgressWriter(out)
_, err = io.Copy(pw, dlResp.Body)
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))
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
}
@@ -201,6 +286,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
return "", err
}
originalFileDir := filepath.Dir(filePath)
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
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)
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",
}
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)
} else {
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("✓ Downloaded successfully from Amazon Music")
return filePath, nil
+25 -15
View File
@@ -145,10 +145,19 @@ func (q *QobuzDownloader) mapJumoQuality(quality string) int {
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
formatID := q.mapJumoQuality(quality)
region := "US"
url := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
url := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
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 {
return "", err
}
@@ -163,7 +172,9 @@ func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (strin
return "", err
}
var result map[string]interface{}
var result struct {
URL string `json:"url"`
}
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 != "" {
return urlVal, 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
if result.URL != "" {
return result.URL, nil
}
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
}
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")
}
+72 -2
View File
@@ -767,8 +767,57 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
if discNumber == 0 {
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
if discInfo["totalDiscs"] != nil {
if totalDiscsFromAlbum > 0 {
totalDiscs = totalDiscsFromAlbum
} else if maxDiscFromAlbum > 0 {
totalDiscs = maxDiscFromAlbum
} else if discInfo["totalDiscs"] != nil {
totalDiscs = discInfo["totalDiscs"].(int)
}
@@ -878,6 +927,11 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
contentRating := getMap(track, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT"
discNumber := int(getFloat64(track, "discNumber"))
if discNumber == 0 {
discNumber = 1
}
trackInfo := map[string]interface{}{
"id": trackID,
"name": getString(track, "name"),
@@ -886,6 +940,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"duration": durationString,
"plays": getString(track, "playcount"),
"is_explicit": isExplicit,
"disc_number": discNumber,
}
tracks = append(tracks, trackInfo)
}
@@ -905,6 +960,12 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
albumID = parts[len(parts)-1]
}
totalDiscs := 1
discsData := getMap(albumData, "discs")
if len(discsData) > 0 {
totalDiscs = int(getFloat64(discsData, "totalCount"))
}
filtered := map[string]interface{}{
"id": albumID,
"name": getString(albumData, "name"),
@@ -913,6 +974,9 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"releaseDate": releaseDate,
"count": len(tracks),
"tracks": tracks,
"discs": map[string]interface{}{
"totalCount": totalDiscs,
},
}
return filtered
@@ -1103,10 +1167,15 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
contentRating := getMap(trackData, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT"
trackName := getString(trackData, "name")
if trackName == "" {
continue
}
trackInfo := map[string]interface{}{
"id": trackID,
"cover": trackCover,
"title": getString(trackData, "name"),
"title": trackName,
"artist": artistsString,
"artistIds": artistIDs,
"plays": rank,
@@ -1116,6 +1185,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
"albumId": albumID,
"duration": durationString,
"is_explicit": isExplicit,
"disc_number": int(getFloat64(trackData, "discNumber")),
}
tracks = append(tracks, trackInfo)
}
+101
View File
@@ -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
View File
@@ -210,6 +210,9 @@ type apiAlbumResponse struct {
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
Count int `json:"count"`
Discs struct {
TotalCount int `json:"totalCount"`
} `json:"discs"`
Tracks []struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -218,6 +221,7 @@ type apiAlbumResponse struct {
Duration string `json:"duration"`
Plays string `json:"plays"`
IsExplicit bool `json:"is_explicit"`
DiscNumber int `json:"disc_number"`
} `json:"tracks"`
}
@@ -245,6 +249,7 @@ type apiPlaylistResponse struct {
AlbumID string `json:"albumId"`
Duration string `json:"duration"`
IsExplicit bool `json:"is_explicit"`
DiscNumber int `json:"disc_number"`
} `json:"tracks"`
}
@@ -432,22 +437,45 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
}
if albumID != "" {
albumPayload := map[string]interface{}{
"variables": map[string]interface{}{
"uri": fmt.Sprintf("spotify:album:%s", albumID),
"locale": "",
"offset": 0,
"limit": 1,
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
if err == nil && albumResponse != nil {
albumJSON, _ := json.Marshal(albumResponse)
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,
TrackNumber: trackNumber,
TotalTracks: raw.Count,
DiscNumber: 1,
TotalDiscs: 0,
DiscNumber: item.DiscNumber,
TotalDiscs: raw.Discs.TotalCount,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID,
AlbumID: raw.ID,
@@ -974,7 +1002,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
ReleaseDate: "",
TrackNumber: 0,
TotalTracks: 0,
DiscNumber: 1,
DiscNumber: item.DiscNumber,
TotalDiscs: 0,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID,
@@ -1094,7 +1122,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
ReleaseDate: albumData.ReleaseDate,
TrackNumber: trackNumber,
TotalTracks: albumData.Count,
DiscNumber: 1,
DiscNumber: tr.DiscNumber,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
ISRC: tr.ID,
AlbumID: albumID,
+76 -17
View File
@@ -101,6 +101,8 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
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...")
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)
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 {
fmt.Printf("✗ Tidal API request failed: %v\n", 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)
}
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 {
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 {
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil {
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,
}
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...")
resp, err := client.Get(directURL)
resp, err := doRequest(directURL)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -283,16 +309,48 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
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)
tempPath := outputPath + ".m4a.tmp"
out, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
fmt.Print("Downloading init segment... ")
resp, err := client.Get(initURL)
resp, err := doRequest(initURL)
if err != nil {
out.Close()
os.Remove(tempPath)
@@ -318,7 +376,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
lastTime := time.Now()
var lastBytes int64
for i, mediaURL := range mediaURLs {
resp, err := client.Get(mediaURL)
resp, err := doRequest(mediaURL)
if err != nil {
out.Close()
os.Remove(tempPath)
@@ -359,6 +417,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
tempInfo, _ := os.Stat(tempPath)
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
}
fmt.Println("Converting to FLAC...")
ffmpegPath, err := GetFFmpegPath()
@@ -633,10 +692,10 @@ type MPD struct {
} `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)
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)
@@ -644,15 +703,15 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
var btsManifest TidalBTSManifest
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 {
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)
return btsManifest.URLs[0], "", nil, nil
return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil
}
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))
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, nil
return "", initURL, mediaURLs, "", nil
}
fmt.Println("Using regex fallback for DASH manifest...")
@@ -733,7 +792,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
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, "&amp;", "&")
@@ -754,7 +813,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
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)
@@ -764,7 +823,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, nil
return "", initURL, mediaURLs, "", nil
}
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
+45 -1
View File
@@ -66,7 +66,12 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
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 {
return "", err
}
@@ -113,6 +118,45 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
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) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
+1
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
+1 -1
View File
@@ -1 +1 @@
629a5f17426ea4202a25837a341483dd
9fee02ec6592ede9ade4b36d56bd4d6d
+71
View File
@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-label':
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)
'@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':
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)
@@ -467,89 +470,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -767,6 +786,19 @@ packages:
'@types/react-dom':
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':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -1092,66 +1124,79 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -1221,24 +1266,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -1723,24 +1772,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -2658,6 +2711,24 @@ snapshots:
'@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)':
dependencies:
'@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

+13
View File
@@ -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

+30 -2
View File
@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
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 ChatGPTTTSIcon from "@/assets/chatgpt-tts.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 XBatchDLIcon from "@/assets/icons/xbatchdl.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 { ScrollArea } from "@/components/ui/scroll-area";
import { DragDropMedia } from "./DragDropTextarea";
@@ -24,7 +26,7 @@ interface AboutPageProps {
export function AboutPage({ version }: AboutPageProps) {
const [os, setOs] = 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 [problem, setProblem] = useState("");
const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -266,6 +268,10 @@ ${contextContent}`;
<Blocks className="h-4 w-4"/>
Other Projects
</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 className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
@@ -433,6 +439,28 @@ ${contextContent}`;
</Card>
</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>);
}
@@ -243,7 +243,7 @@ export function AudioConverterPage() {
codec: outputFormat === "m4a" ? m4aCodec : "",
});
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) {
return {
...f,
+22 -1
View File
@@ -1,7 +1,9 @@
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 { 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> = {
info: "text-blue-500",
success: "text-green-500",
@@ -51,10 +53,29 @@ export function DebugLoggerPage() {
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">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1>
<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}>
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy
+38 -7
View File
@@ -1,9 +1,9 @@
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 { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
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 { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps {
@@ -59,6 +59,21 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
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) => {
switch (status) {
case "downloading":
@@ -105,6 +120,15 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
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}>
<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">
@@ -115,6 +139,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
<Trash2 className="h-3 w-3"/>
Clear History
</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}>
<X className="h-4 w-4"/>
</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-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"/>
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</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"/>
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</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"/>
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</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"/>
<span className="text-muted-foreground">Failed:</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">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p>No downloads in queue</p>
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
</div>) : 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="mt-1">{getStatusIcon(item.status)}</div>
+6 -2
View File
@@ -130,8 +130,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}
});
setFilteredDownloadHistory(result);
setDownloadCurrentPage(1);
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
useEffect(() => {
setDownloadCurrentPage(1);
}, [downloadSearchQuery, downloadSortBy]);
useEffect(() => {
let result = [...fetchHistory];
if (activeFetchTab !== "all") {
@@ -144,8 +146,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}
result.sort((a, b) => b.timestamp - a.timestamp);
setFilteredFetchHistory(result);
setFetchCurrentPage(1);
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
useEffect(() => {
setFetchCurrentPage(1);
}, [fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
audioRef.current?.pause();
+286 -105
View File
@@ -4,11 +4,11 @@ import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, Settings, FolderCog, } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
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 { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -34,17 +34,17 @@ interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void;
}
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
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 hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
flushSync(() => {
setTempSettings(freshSavedSettings);
setIsDark(document.documentElement.classList.contains('dark'));
setIsDark(document.documentElement.classList.contains("dark"));
});
}, []);
useEffect(() => {
@@ -73,7 +73,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
setIsDark(document.documentElement.classList.contains("dark"));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
@@ -124,17 +124,43 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
const handleAutoQualityChange = async (value: "16" | "24") => {
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>
<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="space-y-1">
<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-4">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<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">
<FolderOpen className="h-4 w-4"/>
Browse
@@ -142,8 +168,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</div>
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode">
@@ -157,8 +182,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</Select>
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme">
@@ -168,7 +192,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
backgroundColor: isDark
? theme.cssVars.dark.primary
: theme.cssVars.light.primary,
}}/>
{theme.label}
</span>
@@ -177,8 +203,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</Select>
</div>
<div className="space-y-1">
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font">
@@ -186,84 +211,165 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</SelectTrigger>
<SelectContent>
{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>))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
<div className="flex items-center gap-3 pt-2">
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
sfxEnabled: checked,
}))}/>
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm font-normal">
Sound Effects
</Label>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="downloader" className="text-sm">Source</Label>
<div className="flex gap-2">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<div className="flex gap-2 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({
...prev,
downloader: value,
}))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center"><TidalIcon />Tidal</span>
<span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center"><QobuzIcon />Qobuz</span>
<span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
<span className="flex items-center">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
{tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({ ...prev, autoOrder: value }))}>
<SelectTrigger className="h-9 w-fit">
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
...prev,
autoOrder: value,
}))}>
<SelectTrigger className="h-9 w-fit min-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<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 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 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 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 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 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 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 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 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 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 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 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>
</SelectContent>
</Select>
@@ -285,7 +391,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</SelectTrigger>
<SelectContent>
<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>
</Select>)}
@@ -300,37 +408,56 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</Select>)}
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit/44.1kHz / 24-bit/48kHz
16-bit - 24-bit/44.1kHz - 192kHz
</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 className="border-t pt-6"/>
{((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 pl-1">
<div className="flex items-center space-x-2">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/>
<Label htmlFor="allow-fallback" className="text-sm font-normal">Allow Quality Fallback (16-bit)</Label>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedLyrics: checked,
}))}/>
<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 className="flex items-center gap-6">
<div className="flex items-center gap-3">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
</div>
</div>
<div className="border-t"/>
<div className="space-y-1">
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
@@ -338,37 +465,83 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<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>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
setTempSettings((prev) => ({
...prev,
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">
<SelectValue />
</SelectTrigger>
<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>
</Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
folderTemplate: e.target.value,
}))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
</div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
Preview:{" "}
<span className="font-mono">
{tempSettings.folderTemplate
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
.replace(/\{album\}/g, "Black Panther")
.replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{year\}/g, "2018")}
/
</span>
</p>)}
</div>
<div className="border-t"/>
<div className="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">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
@@ -376,63 +549,71 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<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>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
setTempSettings((prev) => ({
...prev,
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">
<SelectValue />
</SelectTrigger>
<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>
</Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
filenameTemplate: e.target.value,
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
Preview:{" "}
<span className="font-mono">
{tempSettings.filenameTemplate
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
.replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{title\}/g, "All The Stars")
.replace(/\{track\}/g, "01")
.replace(/\{disc\}/g, "1")
.replace(/\{year\}/g, "2018")}
.flac
</span>
</p>)}
</div>
</div>)}
</div>
</div>
<div 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}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<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>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>
Cancel
</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+21 -8
View File
@@ -10,6 +10,9 @@ import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
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";
interface SidebarProps {
currentPage: PageType;
@@ -109,16 +112,26 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<p>About</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<div className="relative group">
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Every coffee helps me keep going</p>
</TooltipContent>
</Tooltip>
<div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
<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>);
}
+46 -2
View File
@@ -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 { 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() {
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 = () => {
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-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">
<Minus className="w-3.5 h-3.5"/>
</button>
+60
View File
@@ -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, };
+65 -14
View File
@@ -5,6 +5,13 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
function getFirstArtist(artistString: string): string {
if (!artistString)
return artistString;
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
const parts = artistString.split(delimiters);
return parts[0].trim();
}
interface CheckFileExistenceRequest {
spotify_id: string;
track_name: string;
@@ -28,8 +35,9 @@ interface FileExistenceResult {
track_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 CreateM3U8File = (playlistName: string, outputDir: string, filePaths: string[]): Promise<void> => (window as any)["go"]["main"]["App"]["CreateM3U8File"](playlistName, outputDir, filePaths);
export function useDownload(region: string) {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
@@ -74,10 +82,16 @@ export function useDownload(region: string) {
if (hasSubfolder) {
useAlbumTrackNumber = true;
}
const displayArtist = settings.useFirstArtistOnly && artistName
? getFirstArtist(artistName)
: artistName;
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
? getFirstArtist(albumArtist)
: albumArtist;
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: trackNumberForTemplate,
year: yearValue,
@@ -85,7 +99,7 @@ export function useDownload(region: string) {
};
const folderTemplate = settings.folderTemplate || "";
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));
}
if (settings.folderTemplate) {
@@ -105,9 +119,9 @@ export function useDownload(region: string) {
const checkRequest: CheckFileExistenceRequest = {
spotify_id: spotifyId || isrc,
track_name: trackName,
artist_name: artistName,
artist_name: displayArtist || "",
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
track_number: finalTrackNumber || spotifyTrackNumber || 0,
disc_number: spotifyDiscNumber || 0,
@@ -117,7 +131,7 @@ export function useDownload(region: string) {
include_track_number: settings.trackNumber || false,
audio_format: serviceForCheck,
};
const existenceResults = await CheckFilesExistence(outputDir, [checkRequest]);
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, [checkRequest]);
if (existenceResults.length > 0 && existenceResults[0].exists) {
fileExists = true;
return {
@@ -135,7 +149,7 @@ export function useDownload(region: string) {
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
let itemID: string | undefined;
if (!fileExists) {
itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
itemID = await AddToDownloadQueue(isrc, trackName || "", displayArtist || "", albumName || "");
}
if (service === "auto") {
let streamingURLs: any = null;
@@ -375,7 +389,7 @@ export function useDownload(region: string) {
};
const folderTemplate = settings.folderTemplate || "";
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));
}
if (settings.folderTemplate) {
@@ -628,7 +642,7 @@ export function useDownload(region: string) {
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
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));
}
const selectedTrackObjects = selectedTracks
@@ -654,13 +668,15 @@ export function useDownload(region: string) {
audio_format: audioFormat,
};
});
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
const existingSpotifyIDs = new Set<string>();
const existingFilePaths = new Map<string, string>();
const finalFilePaths = new Map<string, string>();
for (const result of existenceResults) {
if (result.exists) {
existingSpotifyIDs.add(result.spotify_id);
existingFilePaths.set(result.spotify_id, result.file_path || "");
finalFilePaths.set(result.spotify_id, result.file_path || "");
}
}
logger.info(`found ${existingSpotifyIDs.size} existing files`);
@@ -711,6 +727,10 @@ export function useDownload(region: string) {
successCount++;
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));
setFailedTracks((prev) => {
const newSet = new Set(prev);
@@ -743,6 +763,20 @@ export function useDownload(region: string) {
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
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`);
if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`);
@@ -777,7 +811,7 @@ export function useDownload(region: string) {
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
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));
}
logger.info(`checking existing files in parallel...`);
@@ -800,13 +834,16 @@ export function useDownload(region: string) {
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 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) {
existingSpotifyIDs.add(result.spotify_id);
existingFilePaths.set(result.spotify_id, result.file_path || "");
finalFilePaths[i] = result.file_path || "";
}
}
logger.info(`found ${existingSpotifyIDs.size} existing files`);
@@ -861,6 +898,9 @@ export function useDownload(region: string) {
newSet.delete(track.isrc);
return newSet;
});
if (response.file) {
finalFilePaths[originalIndex] = response.file;
}
}
else {
errorCount++;
@@ -885,6 +925,17 @@ export function useDownload(region: string) {
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
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`);
if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`);
+20 -1
View File
@@ -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";
autoQuality: "16" | "24";
allowFallback: boolean;
useSpotFetchAPI: boolean;
spotFetchAPIUrl: string;
createPlaylistFolder: boolean;
createM3u8File: boolean;
useFirstArtistOnly: boolean;
}
export const FOLDER_PRESETS: Record<FolderPreset, {
label: string;
@@ -101,7 +106,12 @@ export const DEFAULT_SETTINGS: Settings = {
amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon",
autoQuality: "16",
allowFallback: true
allowFallback: true,
useSpotFetchAPI: false,
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
createPlaylistFolder: true,
createM3u8File: false,
useFirstArtistOnly: false
};
export const FONT_OPTIONS: {
value: FontFamily;
@@ -290,6 +300,15 @@ export async function loadSettings(): Promise<Settings> {
if (!('allowFallback' in parsed)) {
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 };
return cachedSettings!;
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function sanitizePath(input: string, os: string): string {
let sanitized = input.trim();
const sanitized = input.trim();
if (os === "Windows") {
return sanitized.replace(/[<>:"/\\|?*]/g, "_");
}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "7.0.7",
"productVersion": "7.0.8",
"copyright": "© 2026 afkarxyz"
},
"wailsjsdir": "./frontend",