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 github: afkarxyz
ko_fi: 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 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) [![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) ![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<div align="center"> <div align="center">
@@ -78,7 +76,8 @@ _If this software is useful and brings you value,
consider supporting the project by buying me a coffee. consider supporting the project by buying me a coffee.
Your support helps keep development going._ Your support helps keep development going._
[![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 ## 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))) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second)))
defer cancel() defer cancel()
settings, err := a.LoadSettings()
if err == nil && settings != nil {
if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
if err != nil {
return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
}
}
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second))) data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to fetch metadata: %v", err) return "", fmt.Errorf("failed to fetch metadata: %v", err)
@@ -592,6 +613,76 @@ func (a *App) CancelAllQueuedItems() {
backend.CancelAllQueuedItems() backend.CancelAllQueuedItems()
} }
func (a *App) ExportFailedDownloads() (string, error) {
queueInfo := backend.GetDownloadQueue()
var failedItems []string
hasFailed := false
for _, item := range queueInfo.Queue {
if item.Status == backend.StatusFailed {
hasFailed = true
break
}
}
if !hasFailed {
return "No failed downloads to export.", nil
}
failedItems = append(failedItems, fmt.Sprintf("Failed Downloads Report - %s", time.Now().Format("2006-01-02 15:04:05")))
failedItems = append(failedItems, strings.Repeat("-", 50))
failedItems = append(failedItems, "")
count := 0
for _, item := range queueInfo.Queue {
if item.Status == backend.StatusFailed {
count++
line := fmt.Sprintf("%d. %s - %s", count, item.TrackName, item.ArtistName)
if item.AlbumName != "" {
line += fmt.Sprintf(" (%s)", item.AlbumName)
}
failedItems = append(failedItems, line)
failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage))
if item.ISRC != "" {
failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.ISRC))
if !strings.HasPrefix(item.ISRC, "http") {
failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.ISRC))
}
}
failedItems = append(failedItems, "")
}
}
content := strings.Join(failedItems, "\n")
defaultFilename := fmt.Sprintf("SpotiFLAC_%s_Failed.txt", time.Now().Format("20060102_150405"))
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: defaultFilename,
Title: "Export Failed Downloads",
Filters: []runtime.FileFilter{
{
DisplayName: "Text Files (*.txt)",
Pattern: "*.txt",
},
},
})
if err != nil {
return "", fmt.Errorf("failed to open save dialog: %v", err)
}
if path == "" {
return "Export cancelled", nil
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return "", fmt.Errorf("failed to write file: %v", err)
}
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
}
func (a *App) Quit() { func (a *App) Quit() {
panic("quit") panic("quit")
@@ -1091,12 +1182,15 @@ type CheckFileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"` ArtistName string `json:"artist_name,omitempty"`
} }
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult { func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
if len(tracks) == 0 { if len(tracks) == 0 {
return []CheckFileExistenceResult{} return []CheckFileExistenceResult{}
} }
outputDir = backend.NormalizePath(outputDir) outputDir = backend.NormalizePath(outputDir)
if rootDir != "" {
rootDir = backend.NormalizePath(rootDir)
}
defaultFilenameFormat := "title-artist" defaultFilenameFormat := "title-artist"
@@ -1107,6 +1201,30 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
resultsChan := make(chan result, len(tracks)) resultsChan := make(chan result, len(tracks))
var rootDirFiles map[string]string
rootDirFilesOnce := false
getRootDirFiles := func() map[string]string {
if rootDirFilesOnce {
return rootDirFiles
}
rootDirFiles = make(map[string]string)
if rootDir != "" && rootDir != outputDir {
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
rootDirFiles[info.Name()] = path
}
}
return nil
})
}
rootDirFilesOnce = true
return rootDirFiles
}
for i, track := range tracks { for i, track := range tracks {
go func(idx int, t CheckFileExistenceRequest) { go func(idx int, t CheckFileExistenceRequest) {
res := CheckFileExistenceResult{ res := CheckFileExistenceResult{
@@ -1163,6 +1281,9 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
res.Exists = true res.Exists = true
res.FilePath = expectedPath res.FilePath = expectedPath
} else {
res.FilePath = expectedFilename
} }
resultsChan <- result{index: idx, result: res} resultsChan <- result{index: idx, result: res}
@@ -1170,9 +1291,39 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
} }
results := make([]CheckFileExistenceResult, len(tracks)) results := make([]CheckFileExistenceResult, len(tracks))
missingIndices := []int{}
for i := 0; i < len(tracks); i++ { for i := 0; i < len(tracks); i++ {
r := <-resultsChan r := <-resultsChan
results[r.index] = r.result results[r.index] = r.result
if !results[r.index].Exists {
missingIndices = append(missingIndices, r.index)
}
}
if len(missingIndices) > 0 && rootDir != "" {
filesMap := getRootDirFiles()
if len(filesMap) > 0 {
for _, idx := range missingIndices {
expectedFilename := results[idx].FilePath
baseName := filepath.Base(expectedFilename)
if path, ok := filesMap[baseName]; ok {
results[idx].Exists = true
results[idx].FilePath = path
} else {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
} }
return results return results
@@ -1245,3 +1396,52 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
func (a *App) GetOSInfo() (string, error) { func (a *App) GetOSInfo() (string, error) {
return backend.GetOSInfo() return backend.GetOSInfo()
} }
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
if len(filePaths) == 0 {
return nil
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return err
}
fnName := m3u8Name
safeName := backend.SanitizeFilename(fnName)
if safeName == "" {
safeName = "playlist"
}
m3u8Path := filepath.Join(outputDir, safeName+".m3u8")
f, err := os.Create(m3u8Path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString("#EXTM3U\n"); err != nil {
return err
}
for _, path := range filePaths {
if path == "" {
continue
}
relPath, err := filepath.Rel(outputDir, path)
if err != nil {
relPath = path
}
relPath = filepath.ToSlash(relPath)
if _, err := f.WriteString(relPath + "\n"); err != nil {
return err
}
}
return nil
}
+129 -25
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -25,13 +26,9 @@ type SongLinkResponse struct {
} `json:"linksByPlatform"` } `json:"linksByPlatform"`
} }
type AfkarXYZResponse struct { type AmazonStreamResponse struct {
Success bool `json:"success"` StreamURL string `json:"streamUrl"`
Data struct { DecryptionKey string `json:"decryptionKey"`
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
} `json:"data"`
} }
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
@@ -55,6 +52,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
fmt.Println("Getting Amazon URL...") fmt.Println("Getting Amazon URL...")
@@ -108,13 +106,21 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
} }
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) { func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
asin := asinRegex.FindString(amazonURL)
if asin == "" {
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
}
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
fmt.Printf("Fetching from AfkarXYZ...\n") fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
@@ -122,27 +128,25 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode) return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
} }
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, err := io.ReadAll(resp.Body)
var apiResp AfkarXYZResponse if err != nil {
return "", err
}
var apiResp AmazonStreamResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err) return "", fmt.Errorf("failed to decode response: %w", err)
} }
if !apiResp.Success || apiResp.Data.DirectLink == "" { if apiResp.StreamURL == "" {
return "", fmt.Errorf("AfkarXYZ failed or no link found") return "", fmt.Errorf("no stream URL found in response")
} }
downloadURL := apiResp.Data.DirectLink downloadURL := apiResp.StreamURL
fileName := apiResp.Data.FileName fileName := fmt.Sprintf("%s.m4a", asin)
if fileName == "" {
fileName = "track.flac"
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
filePath := filepath.Join(outputDir, fileName) filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath) out, err := os.Create(filePath)
@@ -152,6 +156,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
defer out.Close() defer out.Close()
dlReq, _ := http.NewRequest("GET", downloadURL, nil) dlReq, _ := http.NewRequest("GET", downloadURL, nil)
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
dlResp, err := a.client.Do(dlReq) dlResp, err := a.client.Do(dlReq)
if err != nil { if err != nil {
@@ -159,7 +164,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
} }
defer dlResp.Body.Close() defer dlResp.Body.Close()
fmt.Printf("Downloading from AfkarXYZ: %s\n", fileName) fmt.Printf("Downloading track: %s\n", fileName)
pw := NewProgressWriter(out) pw := NewProgressWriter(out)
_, err = io.Copy(pw, dlResp.Body) _, err = io.Copy(pw, dlResp.Body)
if err != nil { if err != nil {
@@ -169,6 +174,86 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
} }
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
if apiResp.DecryptionKey != "" {
fmt.Printf("Decrypting file...\n")
ffprobePath, err := GetFFprobePath()
var codec string
if err == nil {
cmdProbe := exec.Command(ffprobePath,
"-v", "quiet",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
)
setHideWindow(cmdProbe)
codecOutput, _ := cmdProbe.Output()
codec = strings.TrimSpace(string(codecOutput))
fmt.Printf("Detected codec: %s\n", codec)
}
targetExt := ".m4a"
if codec == "flac" {
targetExt = ".flac"
}
decryptedFilename := "dec_" + fileName + targetExt
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
}
decryptedPath := filepath.Join(outputDir, decryptedFilename)
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
}
key := strings.TrimSpace(apiResp.DecryptionKey)
cmd := exec.Command(ffmpegPath,
"-decryption_key", key,
"-i", filePath,
"-c", "copy",
"-y",
decryptedPath,
)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
outStr := string(output)
if len(outStr) > 500 {
outStr = outStr[len(outStr)-500:]
}
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
}
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
return "", fmt.Errorf("decrypted file missing or empty")
}
if err := os.Remove(filePath); err != nil {
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
}
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
if err := os.Rename(decryptedPath, finalPath); err != nil {
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
}
filePath = finalPath
fmt.Println("Decryption successful")
}
return filePath, nil return filePath, nil
} }
@@ -201,6 +286,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
return "", err return "", err
} }
originalFileDir := filepath.Dir(filePath)
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if spotifyTrackName != "" && spotifyArtistName != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName) safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName) safeTitle := sanitizeFilename(spotifyTrackName)
@@ -252,7 +340,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
} }
} }
newFilename = newFilename + ".flac" ext := filepath.Ext(filePath)
if ext == "" {
ext = ".flac"
}
newFilename = newFilename + ext
newFilePath := filepath.Join(outputDir, newFilename) newFilePath := filepath.Join(outputDir, newFilename)
if err := os.Rename(filePath, newFilePath); err != nil { if err := os.Rename(filePath, newFilePath); err != nil {
@@ -300,12 +392,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
} }
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil { if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed metadata: %v\n", err) fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else { } else {
fmt.Println("Metadata embedded successfully") fmt.Println("Metadata embedded successfully")
} }
if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
if _, err := os.Stat(originalM4aPath); err == nil {
if err := os.Remove(originalM4aPath); err != nil {
fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
} else {
fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
}
}
}
fmt.Println("Done") fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Amazon Music") fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil return filePath, nil
+25 -15
View File
@@ -145,10 +145,19 @@ func (q *QobuzDownloader) mapJumoQuality(quality string) int {
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) { func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
formatID := q.mapJumoQuality(quality) formatID := q.mapJumoQuality(quality)
region := "US" region := "US"
url := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&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} client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -163,7 +172,9 @@ func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (strin
return "", err return "", err
} }
var result map[string]interface{} var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
@@ -173,18 +184,8 @@ func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (strin
} }
} }
if urlVal, ok := result["url"].(string); ok && urlVal != "" { if result.URL != "" {
return urlVal, nil return result.URL, nil
}
if data, ok := result["data"].(map[string]interface{}); ok {
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
return urlVal, nil
}
}
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
return linkVal, nil
} }
return "", fmt.Errorf("URL not found in Jumo response") return "", fmt.Errorf("URL not found in Jumo response")
@@ -216,6 +217,15 @@ func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, qu
return streamResp.URL, nil return streamResp.URL, nil
} }
var nestedResp struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
return nestedResp.Data.URL, nil
}
return "", fmt.Errorf("invalid response") return "", fmt.Errorf("invalid response")
} }
+72 -2
View File
@@ -767,8 +767,57 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
if discNumber == 0 { if discNumber == 0 {
discNumber = 1 discNumber = 1
} }
maxDiscFromAlbum := 0
totalDiscsFromAlbum := 0
if len(albumFetchData) > 0 && albumFetchData[0] != nil {
albumUnion := getMap(getMap(albumFetchData[0], "data"), "albumUnion")
if len(albumUnion) > 0 {
discsData := getMap(albumUnion, "discs")
if len(discsData) > 0 {
totalDiscsFromAlbum = int(getFloat64(discsData, "totalCount"))
}
albumTracks := getMap(albumUnion, "tracks")
if len(albumTracks) > 0 {
albumTrackItems := getSlice(albumTracks, "items")
currentTrackID := getString(trackData, "id")
for idx, item := range albumTrackItems {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
trackItem := getMap(itemMap, "track")
if len(trackItem) > 0 {
dNum := int(getFloat64(trackItem, "discNumber"))
if dNum > maxDiscFromAlbum {
maxDiscFromAlbum = dNum
}
trackURI := getString(trackItem, "uri")
if strings.Contains(trackURI, currentTrackID) || getString(trackItem, "id") == currentTrackID {
if dNum > 0 {
discNumber = dNum
}
}
trackNum := int(getFloat64(trackData, "trackNumber"))
itemTrackNum := idx + 1
if trackNum == itemTrackNum && dNum > 0 {
}
}
}
}
}
}
totalDiscs := 1 totalDiscs := 1
if discInfo["totalDiscs"] != nil { if totalDiscsFromAlbum > 0 {
totalDiscs = totalDiscsFromAlbum
} else if maxDiscFromAlbum > 0 {
totalDiscs = maxDiscFromAlbum
} else if discInfo["totalDiscs"] != nil {
totalDiscs = discInfo["totalDiscs"].(int) totalDiscs = discInfo["totalDiscs"].(int)
} }
@@ -878,6 +927,11 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
contentRating := getMap(track, "contentRating") contentRating := getMap(track, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT" isExplicit := getString(contentRating, "label") == "EXPLICIT"
discNumber := int(getFloat64(track, "discNumber"))
if discNumber == 0 {
discNumber = 1
}
trackInfo := map[string]interface{}{ trackInfo := map[string]interface{}{
"id": trackID, "id": trackID,
"name": getString(track, "name"), "name": getString(track, "name"),
@@ -886,6 +940,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"duration": durationString, "duration": durationString,
"plays": getString(track, "playcount"), "plays": getString(track, "playcount"),
"is_explicit": isExplicit, "is_explicit": isExplicit,
"disc_number": discNumber,
} }
tracks = append(tracks, trackInfo) tracks = append(tracks, trackInfo)
} }
@@ -905,6 +960,12 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
albumID = parts[len(parts)-1] albumID = parts[len(parts)-1]
} }
totalDiscs := 1
discsData := getMap(albumData, "discs")
if len(discsData) > 0 {
totalDiscs = int(getFloat64(discsData, "totalCount"))
}
filtered := map[string]interface{}{ filtered := map[string]interface{}{
"id": albumID, "id": albumID,
"name": getString(albumData, "name"), "name": getString(albumData, "name"),
@@ -913,6 +974,9 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"releaseDate": releaseDate, "releaseDate": releaseDate,
"count": len(tracks), "count": len(tracks),
"tracks": tracks, "tracks": tracks,
"discs": map[string]interface{}{
"totalCount": totalDiscs,
},
} }
return filtered return filtered
@@ -1103,10 +1167,15 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
contentRating := getMap(trackData, "contentRating") contentRating := getMap(trackData, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT" isExplicit := getString(contentRating, "label") == "EXPLICIT"
trackName := getString(trackData, "name")
if trackName == "" {
continue
}
trackInfo := map[string]interface{}{ trackInfo := map[string]interface{}{
"id": trackID, "id": trackID,
"cover": trackCover, "cover": trackCover,
"title": getString(trackData, "name"), "title": trackName,
"artist": artistsString, "artist": artistsString,
"artistIds": artistIDs, "artistIds": artistIDs,
"plays": rank, "plays": rank,
@@ -1116,6 +1185,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
"albumId": albumID, "albumId": albumID,
"duration": durationString, "duration": durationString,
"is_explicit": isExplicit, "is_explicit": isExplicit,
"disc_number": int(getFloat64(trackData, "discNumber")),
} }
tracks = append(tracks, trackInfo) tracks = append(tracks, trackInfo)
} }
+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"` Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"` ReleaseDate string `json:"releaseDate"`
Count int `json:"count"` Count int `json:"count"`
Discs struct {
TotalCount int `json:"totalCount"`
} `json:"discs"`
Tracks []struct { Tracks []struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -218,6 +221,7 @@ type apiAlbumResponse struct {
Duration string `json:"duration"` Duration string `json:"duration"`
Plays string `json:"plays"` Plays string `json:"plays"`
IsExplicit bool `json:"is_explicit"` IsExplicit bool `json:"is_explicit"`
DiscNumber int `json:"disc_number"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -245,6 +249,7 @@ type apiPlaylistResponse struct {
AlbumID string `json:"albumId"` AlbumID string `json:"albumId"`
Duration string `json:"duration"` Duration string `json:"duration"`
IsExplicit bool `json:"is_explicit"` IsExplicit bool `json:"is_explicit"`
DiscNumber int `json:"disc_number"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -432,22 +437,45 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
} }
if albumID != "" { if albumID != "" {
albumPayload := map[string]interface{}{
"variables": map[string]interface{}{ albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
"uri": fmt.Sprintf("spotify:album:%s", albumID), if err == nil && albumResponse != nil {
"locale": "",
"offset": 0, albumJSON, _ := json.Marshal(albumResponse)
"limit": 1, var albumMap map[string]interface{}
json.Unmarshal(albumJSON, &albumMap)
tracksItems := []interface{}{}
if albumMap["tracks"] != nil {
if trackList, ok := albumMap["tracks"].([]interface{}); ok {
for _, t := range trackList {
if trackMap, ok := t.(map[string]interface{}); ok {
tracksItems = append(tracksItems, map[string]interface{}{
"track": map[string]interface{}{
"discNumber": trackMap["disc_number"],
"id": trackMap["id"],
"uri": fmt.Sprintf("spotify:track:%s", trackMap["id"]),
},
})
}
}
}
}
albumFetchData = map[string]interface{}{
"data": map[string]interface{}{
"albumUnion": map[string]interface{}{
"discs": map[string]interface{}{
"totalCount": albumResponse.Discs.TotalCount,
},
"tracks": map[string]interface{}{
"items": tracksItems,
"totalCount": albumResponse.Count,
}, },
"operationName": "getAlbum",
"extensions": map[string]interface{}{
"persistedQuery": map[string]interface{}{
"version": 1,
"sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
}, },
}, },
} }
albumFetchData, _ = client.Query(albumPayload) }
} }
} }
} }
@@ -914,8 +942,8 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
ReleaseDate: raw.ReleaseDate, ReleaseDate: raw.ReleaseDate,
TrackNumber: trackNumber, TrackNumber: trackNumber,
TotalTracks: raw.Count, TotalTracks: raw.Count,
DiscNumber: 1, DiscNumber: item.DiscNumber,
TotalDiscs: 0, TotalDiscs: raw.Discs.TotalCount,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID, ISRC: item.ID,
AlbumID: raw.ID, AlbumID: raw.ID,
@@ -974,7 +1002,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
ReleaseDate: "", ReleaseDate: "",
TrackNumber: 0, TrackNumber: 0,
TotalTracks: 0, TotalTracks: 0,
DiscNumber: 1, DiscNumber: item.DiscNumber,
TotalDiscs: 0, TotalDiscs: 0,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID, ISRC: item.ID,
@@ -1094,7 +1122,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
ReleaseDate: albumData.ReleaseDate, ReleaseDate: albumData.ReleaseDate,
TrackNumber: trackNumber, TrackNumber: trackNumber,
TotalTracks: albumData.Count, TotalTracks: albumData.Count,
DiscNumber: 1, DiscNumber: tr.DiscNumber,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
ISRC: tr.ID, ISRC: tr.ID,
AlbumID: albumID, AlbumID: albumID,
+76 -17
View File
@@ -101,6 +101,8 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
fmt.Println("Getting Tidal URL...") fmt.Println("Getting Tidal URL...")
resp, err := t.client.Do(req) resp, err := t.client.Do(req)
@@ -157,7 +159,15 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url) fmt.Printf("Tidal API URL: %s\n", url)
resp, err := t.client.Get(url) req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("✗ failed to create request: %v\n", err)
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
resp, err := t.client.Do(req)
if err != nil { if err != nil {
fmt.Printf("✗ Tidal API request failed: %v\n", err) fmt.Printf("✗ Tidal API request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err) return "", fmt.Errorf("failed to get download URL: %w", err)
@@ -214,7 +224,14 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath) return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
} }
resp, err := t.client.Get(url) req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
resp, err := t.client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to download file: %w", err) return fmt.Errorf("failed to download file: %w", err)
@@ -244,7 +261,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
} }
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error { func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
directURL, initURL, mediaURLs, err := parseManifest(manifestB64) directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err) return fmt.Errorf("failed to parse manifest: %w", err)
} }
@@ -253,10 +270,19 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
Timeout: 120 * time.Second, Timeout: 120 * time.Second,
} }
if directURL != "" { doRequest := func(url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
return client.Do(req)
}
if directURL != "" && (strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "") {
fmt.Println("Downloading file...") fmt.Println("Downloading file...")
resp, err := client.Get(directURL) resp, err := doRequest(directURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to download file: %w", err) return fmt.Errorf("failed to download file: %w", err)
} }
@@ -283,16 +309,48 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
return nil return nil
} }
tempPath := outputPath + ".m4a.tmp"
if directURL != "" {
fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType)
resp, err := doRequest(directURL)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
out, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
out.Close()
if err != nil {
os.Remove(tempPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
} else {
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1) fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
tempPath := outputPath + ".m4a.tmp"
out, err := os.Create(tempPath) out, err := os.Create(tempPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create temp file: %w", err) return fmt.Errorf("failed to create temp file: %w", err)
} }
fmt.Print("Downloading init segment... ") fmt.Print("Downloading init segment... ")
resp, err := client.Get(initURL) resp, err := doRequest(initURL)
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(tempPath) os.Remove(tempPath)
@@ -318,7 +376,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
lastTime := time.Now() lastTime := time.Now()
var lastBytes int64 var lastBytes int64
for i, mediaURL := range mediaURLs { for i, mediaURL := range mediaURLs {
resp, err := client.Get(mediaURL) resp, err := doRequest(mediaURL)
if err != nil { if err != nil {
out.Close() out.Close()
os.Remove(tempPath) os.Remove(tempPath)
@@ -359,6 +417,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
tempInfo, _ := os.Stat(tempPath) tempInfo, _ := os.Stat(tempPath)
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024)) fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
}
fmt.Println("Converting to FLAC...") fmt.Println("Converting to FLAC...")
ffmpegPath, err := GetFFmpegPath() ffmpegPath, err := GetFFmpegPath()
@@ -633,10 +692,10 @@ type MPD struct {
} `xml:"Period"` } `xml:"Period"`
} }
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, mimeType string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil { if err != nil {
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) return "", "", nil, "", fmt.Errorf("failed to decode manifest: %w", err)
} }
manifestStr := string(manifestBytes) manifestStr := string(manifestBytes)
@@ -644,15 +703,15 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") { if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
var btsManifest TidalBTSManifest var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) return "", "", nil, "", fmt.Errorf("failed to parse BTS manifest: %w", err)
} }
if len(btsManifest.URLs) == 0 { if len(btsManifest.URLs) == 0 {
return "", "", nil, fmt.Errorf("no URLs in BTS manifest") return "", "", nil, "", fmt.Errorf("no URLs in BTS manifest")
} }
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs) fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
return btsManifest.URLs[0], "", nil, nil return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil
} }
fmt.Println("Manifest: DASH format") fmt.Println("Manifest: DASH format")
@@ -717,7 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
} }
return "", initURL, mediaURLs, nil return "", initURL, mediaURLs, "", nil
} }
fmt.Println("Using regex fallback for DASH manifest...") fmt.Println("Using regex fallback for DASH manifest...")
@@ -733,7 +792,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} }
if initURL == "" { if initURL == "" {
return "", "", nil, fmt.Errorf("no initialization URL found in manifest") return "", "", nil, "", fmt.Errorf("no initialization URL found in manifest")
} }
initURL = strings.ReplaceAll(initURL, "&amp;", "&") initURL = strings.ReplaceAll(initURL, "&amp;", "&")
@@ -754,7 +813,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} }
if segmentCount == 0 { if segmentCount == 0 {
return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches)) return "", "", nil, "", fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
} }
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount) fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
@@ -764,7 +823,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
} }
return "", initURL, mediaURLs, nil return "", initURL, mediaURLs, "", nil
} }
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) { func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
+45 -1
View File
@@ -66,7 +66,12 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
writer.Close() writer.Close()
req, err := http.NewRequest("POST", "https://u1112.send.now/cgi-bin/upload.cgi?upload_type=file&utype=anon", body) uploadURL, err := getUploadURL()
if err != nil {
return "", fmt.Errorf("failed to get upload server: %v", err)
}
req, err := http.NewRequest("POST", uploadURL, body)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -113,6 +118,45 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
return fetchDirectImageLink(downloadLink) return fetchDirectImageLink(downloadLink)
} }
func getUploadURL() (string, error) {
req, err := http.NewRequest("GET", "https://send.now/", nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("failed to fetch main page: status %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
body := string(bodyBytes)
re := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi\?upload_type=file[^"']*)["']`)
matches := re.FindStringSubmatch(body)
if len(matches) > 1 {
return matches[1], nil
}
reFallback := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi)`)
matchesFallback := reFallback.FindStringSubmatch(body)
if len(matchesFallback) > 1 {
return matchesFallback[1] + "?upload_type=file&utype=anon", nil
}
return "", fmt.Errorf("upload URL not found in main page")
}
func fetchDirectImageLink(url string) (string, error) { func fetchDirectImageLink(url string) (string, error) {
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
+1
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
+1 -1
View File
@@ -1 +1 @@
629a5f17426ea4202a25837a341483dd 9fee02ec6592ede9ade4b36d56bd4d6d
+71
View File
@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.1.8 specifier: ^2.1.8
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-menubar':
specifier: ^1.1.16
version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-progress': '@radix-ui/react-progress':
specifier: ^1.1.8 specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -467,89 +470,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4': '@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4': '@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4': '@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4': '@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4': '@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4': '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4': '@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5': '@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5': '@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5': '@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5': '@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5': '@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5': '@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5': '@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5': '@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -767,6 +786,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-menubar@1.1.16':
resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8': '@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies: peerDependencies:
@@ -1092,66 +1124,79 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1': '@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1': '@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1': '@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1': '@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1': '@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1': '@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1': '@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1': '@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1': '@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1': '@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1': '@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1': '@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1': '@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -1221,24 +1266,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18': '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18': '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18': '@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18': '@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -1723,24 +1772,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2: lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2: lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2: lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2: lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -2658,6 +2711,24 @@ snapshots:
'@types/react': 19.2.8 '@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8) '@types/react-dom': 19.2.3(@types/react@19.2.8)
'@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

+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 { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks } from "lucide-react"; import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp"; import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XProIcon from "@/assets/x-pro.webp"; import XProIcon from "@/assets/x-pro.webp";
@@ -15,6 +15,8 @@ import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg"; import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import BmcLogo from "@/assets/bmc-logo.svg";
import KofiLogo from "@/assets/kofi_symbol.svg";
import { langColors } from "@/assets/github-lang-colors"; import { langColors } from "@/assets/github-lang-colors";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { DragDropMedia } from "./DragDropTextarea"; import { DragDropMedia } from "./DragDropTextarea";
@@ -24,7 +26,7 @@ interface AboutPageProps {
export function AboutPage({ version }: AboutPageProps) { export function AboutPage({ version }: AboutPageProps) {
const [os, setOs] = useState("Unknown"); const [os, setOs] = useState("Unknown");
const [location, setLocation] = useState("Unknown"); const [location, setLocation] = useState("Unknown");
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects">("bug_report"); const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
const [bugType, setBugType] = useState("Track"); const [bugType, setBugType] = useState("Track");
const [problem, setProblem] = useState(""); const [problem, setProblem] = useState("");
const [spotifyUrl, setSpotifyUrl] = useState(""); const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -266,6 +268,10 @@ ${contextContent}`;
<Blocks className="h-4 w-4"/> <Blocks className="h-4 w-4"/>
Other Projects Other Projects
</Button> </Button>
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
<Heart className="h-4 w-4"/>
Support Us
</Button>
</div> </div>
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}> <div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
@@ -433,6 +439,28 @@ ${contextContent}`;
</Card> </Card>
</div> </div>
</div>)} </div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold tracking-tight">Support Our Work</h3>
<p className="text-muted-foreground max-w-[500px]">
If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going.
</p>
</div>
<div className="grid sm:grid-cols-2 gap-4 w-full max-w-lg">
<Button size="lg" className="h-16 text-lg font-semibold text-white gap-3 group" style={{ backgroundColor: "#72a4f2" }} onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
Support me on Ko-fi
</Button>
<Button size="lg" className="h-16 text-lg font-semibold text-black gap-3 group" style={{ backgroundColor: "#ffdd00" }} onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")}>
<img src={BmcLogo} className="h-6 w-6 transition-transform group-hover:scale-110" alt="Buy Me a Coffee"/>
Buy Me a Coffee
</Button>
</div>
</div>)}
</div> </div>
</div>); </div>);
} }
@@ -243,7 +243,7 @@ export function AudioConverterPage() {
codec: outputFormat === "m4a" ? m4aCodec : "", codec: outputFormat === "m4a" ? m4aCodec : "",
}); });
setFiles((prev) => prev.map((f) => { setFiles((prev) => prev.map((f) => {
const result = results.find((r) => r.input_file === f.path); const result = results.find((r) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
if (result) { if (result) {
return { return {
...f, ...f,
+22 -1
View File
@@ -1,7 +1,9 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check } from "lucide-react"; import { Trash2, Copy, Check, FileDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger"; import { logger, type LogEntry } from "@/lib/logger";
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
const levelColors: Record<string, string> = { const levelColors: Record<string, string> = {
info: "text-blue-500", info: "text-blue-500",
success: "text-green-500", success: "text-green-500",
@@ -51,10 +53,29 @@ export function DebugLoggerPage() {
console.error("Failed to copy logs:", err); console.error("Failed to copy logs:", err);
} }
}; };
const handleExportFailed = async () => {
try {
const message = await ExportFailedDownloads();
if (message.startsWith("Successfully")) {
toast.success(message);
}
else if (message !== "Export cancelled") {
toast.info(message);
}
}
catch (error) {
console.error("Failed to export:", error);
toast.error(`Failed to export: ${error}`);
}
};
return (<div className="space-y-6"> return (<div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1> <h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed}>
<FileDown className="h-4 w-4"/>
Export Failed
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}> <Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>} {copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy Copy
+38 -7
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react"; import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer, FileDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads } from "../../wailsjs/go/main/App"; import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads, ExportFailedDownloads } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { backend } from "../../wailsjs/go/models"; import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps { interface DownloadQueueProps {
@@ -59,6 +59,21 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
console.error("Failed to reset queue:", error); console.error("Failed to reset queue:", error);
} }
}; };
const handleExportFailed = async () => {
try {
const message = await ExportFailedDownloads();
if (message.startsWith("Successfully")) {
toast.success(message);
}
else if (message !== "Export cancelled") {
toast.info(message);
}
}
catch (error) {
console.error("Failed to export:", error);
toast.error(`Failed to export: ${error}`);
}
};
const getStatusIcon = (status: string) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case "downloading": case "downloading":
@@ -105,6 +120,15 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
return `${seconds}s`; return `${seconds}s`;
} }
}; };
const [filterStatus, setFilterStatus] = useState<string>("all");
const toggleFilter = (status: string) => {
setFilterStatus(prev => prev === status ? "all" : status);
};
const filteredQueue = queueInfo.queue.filter((item: any) => {
if (filterStatus === "all")
return true;
return item.status === filterStatus;
});
return (<Dialog open={isOpen} onOpenChange={onClose}> return (<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden"> <DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0"> <DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
@@ -115,6 +139,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
<Trash2 className="h-3 w-3"/> <Trash2 className="h-3 w-3"/>
Clear History Clear History
</Button>)} </Button>)}
{queueInfo.failed_count > 0 && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleExportFailed}>
<FileDown className="h-3 w-3"/>
Export Failures
</Button>)}
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}> <Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
</Button> </Button>
@@ -123,22 +151,22 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5"> <div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'queued' ? 'bg-secondary px-2 py-0.5 rounded-md ring-1 ring-border' : ''}`} onClick={() => toggleFilter('queued')}>
<Clock className="h-3.5 w-3.5 text-muted-foreground"/> <Clock className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Queued:</span> <span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span> <span className="font-semibold">{queueInfo.queued_count}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'completed' ? 'bg-green-500/10 px-2 py-0.5 rounded-md ring-1 ring-green-500/20' : ''}`} onClick={() => toggleFilter('completed')}>
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/> <CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span> <span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span> <span className="font-semibold">{queueInfo.completed_count}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'skipped' ? 'bg-yellow-500/10 px-2 py-0.5 rounded-md ring-1 ring-yellow-500/20' : ''}`} onClick={() => toggleFilter('skipped')}>
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/> <FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span> <span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span> <span className="font-semibold">{queueInfo.skipped_count}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'failed' ? 'bg-red-500/10 px-2 py-0.5 rounded-md ring-1 ring-red-500/20' : ''}`} onClick={() => toggleFilter('failed')}>
<XCircle className="h-3.5 w-3.5 text-red-500"/> <XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span> <span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span> <span className="font-semibold">{queueInfo.failed_count}</span>
@@ -180,7 +208,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground"> {queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/> <Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p>No downloads in queue</p> <p>No downloads in queue</p>
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors"> </div>) : filteredQueue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<p>No downloads with status "{filterStatus}"</p>
<Button variant="link" onClick={() => setFilterStatus("all")}>Clear filter</Button>
</div>) : (filteredQueue.map((item: any) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div> <div className="mt-1">{getStatusIcon(item.status)}</div>
+6 -2
View File
@@ -130,8 +130,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
} }
}); });
setFilteredDownloadHistory(result); setFilteredDownloadHistory(result);
setDownloadCurrentPage(1);
}, [downloadHistory, downloadSearchQuery, downloadSortBy]); }, [downloadHistory, downloadSearchQuery, downloadSortBy]);
useEffect(() => {
setDownloadCurrentPage(1);
}, [downloadSearchQuery, downloadSortBy]);
useEffect(() => { useEffect(() => {
let result = [...fetchHistory]; let result = [...fetchHistory];
if (activeFetchTab !== "all") { if (activeFetchTab !== "all") {
@@ -144,8 +146,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
} }
result.sort((a, b) => b.timestamp - a.timestamp); result.sort((a, b) => b.timestamp - a.timestamp);
setFilteredFetchHistory(result); setFilteredFetchHistory(result);
setFetchCurrentPage(1);
}, [fetchHistory, fetchSearchQuery, activeFetchTab]); }, [fetchHistory, fetchSearchQuery, activeFetchTab]);
useEffect(() => {
setFetchCurrentPage(1);
}, [fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => { const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) { if (playingPreviewId === id) {
audioRef.current?.pause(); audioRef.current?.pause();
+289 -108
View File
@@ -4,11 +4,11 @@ import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight } from "lucide-react"; import { FolderOpen, Save, RotateCcw, Info, ArrowRight, Settings, FolderCog, } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -17,34 +17,34 @@ const TidalIcon = ({ className }: {
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}> }) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path> <path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path> <path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>); </svg>);
const QobuzIcon = ({ className }: { const QobuzIcon = ({ className }: {
className?: string; className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}> }) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path> <path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path> <path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>); </svg>);
const AmazonIcon = ({ className }: { const AmazonIcon = ({ className }: {
className?: string; className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}> }) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>); </svg>);
interface SettingsPageProps { interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void; onResetRequest?: (resetFn: () => void) => void;
} }
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) { export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings()); const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings); const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark"));
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => { const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings(); const freshSavedSettings = getSettings();
flushSync(() => { flushSync(() => {
setTempSettings(freshSavedSettings); setTempSettings(freshSavedSettings);
setIsDark(document.documentElement.classList.contains('dark')); setIsDark(document.documentElement.classList.contains("dark"));
}); });
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -73,7 +73,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
applyTheme(tempSettings.theme); applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily); applyFont(tempSettings.fontFamily);
setTimeout(() => { setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark')); setIsDark(document.documentElement.classList.contains("dark"));
}, 0); }, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]); }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => { useEffect(() => {
@@ -124,17 +124,43 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
const handleAutoQualityChange = async (value: "16" | "24") => { const handleAutoQualityChange = async (value: "16" | "24") => {
setTempSettings((prev) => ({ ...prev, autoQuality: value })); setTempSettings((prev) => ({ ...prev, autoQuality: value }));
}; };
return (<div className="space-y-4"> const [activeTab, setActiveTab] = useState<"general" | "files">("general");
return (<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4"/>
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4"/>
Save Changes
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
<Settings className="h-4 w-4"/>
General
</Button>
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
<FolderCog className="h-4 w-4"/>
File Management
</Button>
</div>
<div className="space-y-3"> <div className="flex-1 overflow-y-auto pt-4">
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label> <Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/> <InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
...prev,
downloadPath: e.target.value,
}))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5"> <Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/> <FolderOpen className="h-4 w-4"/>
Browse Browse
@@ -142,8 +168,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</div> </div>
</div> </div>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="theme-mode">Mode</Label> <Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}> <Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode"> <SelectTrigger id="theme-mode">
@@ -157,8 +182,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</Select> </Select>
</div> </div>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="theme">Accent</Label> <Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}> <Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme"> <SelectTrigger id="theme">
@@ -168,7 +192,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}> {themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{ <span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary backgroundColor: isDark
? theme.cssVars.dark.primary
: theme.cssVars.light.primary,
}}/> }}/>
{theme.label} {theme.label}
</span> </span>
@@ -177,8 +203,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</Select> </Select>
</div> </div>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="font">Font</Label> <Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}> <Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font"> <SelectTrigger id="font">
@@ -186,84 +211,165 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}> {FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span> <span style={{ fontFamily: font.fontFamily }}>
{font.label}
</span>
</SelectItem>))} </SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-3 pt-2">
<div className="flex items-center gap-3"> <Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings((prev) => ({
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label> ...prev,
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/> sfxEnabled: checked,
}))}/>
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm font-normal">
Sound Effects
</Label>
</div> </div>
</div> </div>
<div className="space-y-4">
<div className="space-y-3"> <div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<div className="space-y-1"> <div className="flex gap-2 flex-wrap">
<Label htmlFor="downloader" className="text-sm">Source</Label> <Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({
<div className="flex gap-2"> ...prev,
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}> downloader: value,
}))}>
<SelectTrigger id="downloader" className="h-9 w-fit"> <SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/> <SelectValue placeholder="Select a source"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal"> <SelectItem value="tidal">
<span className="flex items-center"><TidalIcon />Tidal</span> <span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem> </SelectItem>
<SelectItem value="qobuz"> <SelectItem value="qobuz">
<span className="flex items-center"><QobuzIcon />Qobuz</span> <span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem> </SelectItem>
<SelectItem value="amazon"> <SelectItem value="amazon">
<span className="flex items-center"><AmazonIcon />Amazon Music</span> <span className="flex items-center">
<AmazonIcon />
Amazon Music
</span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.downloader === "auto" && (<> {tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({ ...prev, autoOrder: value }))}> <Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
<SelectTrigger className="h-9 w-fit"> ...prev,
autoOrder: value,
}))}>
<SelectTrigger className="h-9 w-fit min-w-[140px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="tidal-qobuz"> <SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="tidal-amazon"> <SelectItem value="tidal-amazon">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="qobuz-tidal"> <SelectItem value="qobuz-tidal">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="qobuz-amazon"> <SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="amazon-tidal"> <SelectItem value="amazon-tidal">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="amazon-qobuz"> <SelectItem value="amazon-qobuz">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="tidal-qobuz-amazon"> <SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="tidal-amazon-qobuz"> <SelectItem value="tidal-amazon-qobuz">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="qobuz-tidal-amazon"> <SelectItem value="qobuz-tidal-amazon">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="qobuz-amazon-tidal"> <SelectItem value="qobuz-amazon-tidal">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="amazon-tidal-qobuz"> <SelectItem value="amazon-tidal-qobuz">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
<SelectItem value="amazon-qobuz-tidal"> <SelectItem value="amazon-qobuz-tidal">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span> <span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -285,7 +391,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem> <SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem> <SelectItem value="HI_RES_LOSSLESS">
24-bit/48kHz
</SelectItem>
</SelectContent> </SelectContent>
</Select>)} </Select>)}
@@ -300,37 +408,56 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</Select>)} </Select>)}
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default"> {tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit/44.1kHz / 24-bit/48kHz 16-bit - 24-bit/44.1kHz - 192kHz
</div>)} </div>)}
</div> </div>
{((tempSettings.downloader === "tidal" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" &&
tempSettings.qobuzQuality === "7") ||
(tempSettings.downloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
<div className="flex items-center gap-3">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowFallback: checked,
}))}/>
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
Allow Quality Fallback (16-bit)
</Label>
</div>
</div>)}
</div> </div>
<div className="border-t pt-6"/>
{((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") || <div className="space-y-4">
(tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "7") || <div className="flex items-center gap-3">
(tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pl-1"> <Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
<div className="flex items-center space-x-2"> ...prev,
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/> embedLyrics: checked,
<Label htmlFor="allow-fallback" className="text-sm font-normal">Allow Quality Fallback (16-bit)</Label> }))}/>
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
Embed Lyrics
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedMaxQualityCover: checked,
}))}/>
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
Embed Max Quality Cover
</Label>
</div>
</div>
</div> </div>
</div>)} </div>)}
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-6"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="space-y-2">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
</div>
</div>
<div className="border-t"/>
<div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label> <Label className="text-sm">Folder Structure</Label>
<Tooltip> <Tooltip>
@@ -338,37 +465,83 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">
Variables:{" "}
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => { <Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value]; const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({ setTempSettings((prev) => ({
...prev, ...prev,
folderPreset: value, folderPreset: value,
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template folderTemplate: value === "custom"
? prev.folderTemplate || preset.template
: preset.template,
})); }));
}}> }}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
{label}
</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)} {tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
folderTemplate: e.target.value,
}))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
</div> </div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground"> {tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span> Preview:{" "}
<span className="font-mono">
{tempSettings.folderTemplate
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
.replace(/\{album\}/g, "Black Panther")
.replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{year\}/g, "2018")}
/
</span>
</p>)} </p>)}
</div> </div>
<div className="border-t"/> <div className="flex items-center gap-3">
<Switch id="create-playlist-folder" checked={tempSettings.createPlaylistFolder} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
createPlaylistFolder: checked,
}))}/>
<Label htmlFor="create-playlist-folder" className="text-sm cursor-pointer font-normal">
Playlist Folder
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="create-m3u8-file" checked={tempSettings.createM3u8File} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
createM3u8File: checked,
}))}/>
<Label htmlFor="create-m3u8-file" className="text-sm cursor-pointer font-normal">
Create M3U8 Playlist File
</Label>
</div>
<div className="space-y-1"> <div className="flex items-center gap-3">
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useFirstArtistOnly: checked,
}))}/>
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
Use First Artist Only
</Label>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label> <Label className="text-sm">Filename Format</Label>
<Tooltip> <Tooltip>
@@ -376,63 +549,71 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">
Variables:{" "}
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => { <Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value]; const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({ setTempSettings((prev) => ({
...prev, ...prev,
filenamePreset: value, filenamePreset: value,
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template filenameTemplate: value === "custom"
? prev.filenameTemplate || preset.template
: preset.template,
})); }));
}}> }}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
{label}
</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} {tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
filenameTemplate: e.target.value,
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div> </div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground"> {tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span> Preview:{" "}
<span className="font-mono">
{tempSettings.filenameTemplate
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
.replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{title\}/g, "All The Stars")
.replace(/\{track\}/g, "01")
.replace(/\{disc\}/g, "1")
.replace(/\{year\}/g, "2018")}
.flac
</span>
</p>)} </p>)}
</div> </div>
</div>)}
</div> </div>
</div>
<div className="flex gap-2 justify-between pt-3 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4"/>
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4"/>
Save Changes
</Button>
</div>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}> <Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden"> <DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle> <DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription> <DialogDescription>
This will reset all settings to their default values. Your custom configurations will be lost. This will reset all settings to their default values. Your custom
configurations will be lost.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button> <Button variant="outline" onClick={() => setShowResetConfirm(false)}>
Cancel
</Button>
<Button onClick={handleReset}>Reset</Button> <Button onClick={handleReset}>Reset</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>); </div>);
} }
+21 -8
View File
@@ -10,6 +10,9 @@ import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
import BmcLogo from "@/assets/bmc-logo-side.svg";
import BmcLogoWhite from "@/assets/bmc-logo-side-white.svg";
import KofiLogo from "@/assets/kofi_symbol.svg";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
interface SidebarProps { interface SidebarProps {
currentPage: PageType; currentPage: PageType;
@@ -109,16 +112,26 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<p>About</p> <p>About</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <div className="relative group">
<TooltipTrigger asChild> <Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20} loop={true}/> <CoffeeIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Every coffee helps me keep going</p> <div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
</TooltipContent>
</Tooltip> <div className="absolute left-10 bottom-0 mb-0 ml-3 hidden group-hover:flex flex-col gap-1 p-1 bg-popover border border-border rounded-md shadow-md z-50 w-max animate-in fade-in zoom-in-95 duration-200 origin-bottom-left">
<button onClick={() => openExternal("https://ko-fi.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
<img src={KofiLogo} className="h-4 w-4" alt="Ko-fi"/>
Support me on Ko-fi
</button>
<button onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
<img src={BmcLogo} className="h-4 w-4 dark:hidden" alt="BMC"/>
<img src={BmcLogoWhite} className="h-4 w-4 hidden dark:block" alt="BMC"/>
Buy Me a Coffee
</button>
</div>
</div>
</div> </div>
</div>); </div>);
} }
+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 { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getSettings, updateSettings } from "@/lib/settings";
import { useState, useEffect } from "react";
export function TitleBar() { export function TitleBar() {
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
useEffect(() => {
const settings = getSettings();
if (settings) {
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
}
}, []);
const handleSpotFetchAPIToggle = () => {
const newValue = !useSpotFetchAPI;
setUseSpotFetchAPI(newValue);
updateSettings({ useSpotFetchAPI: newValue });
};
const handleMinimize = () => { const handleMinimize = () => {
WindowMinimise(); WindowMinimise();
}; };
@@ -15,7 +31,35 @@ export function TitleBar() {
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/> <div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5"> <div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5 items-center">
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
<MenubarMenu>
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
<Settings className="w-3.5 h-3.5"/>
</MenubarTrigger>
<MenubarContent align="end" className="min-w-[200px]">
<div className="flex items-center gap-1.5 px-2 py-1.5">
<MenubarLabel className="p-0">SpotFetch API</MenubarLabel>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help text-muted-foreground"/>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="font-semibold mb-2">Spotify Blocked Countries:</p>
<p className="text-xs">Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<MenubarSeparator />
<MenubarItem onClick={handleSpotFetchAPIToggle} className="justify-between">
<span>Use SpotFetch API</span>
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize"> <button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
<Minus className="w-3.5 h-3.5"/> <Minus className="w-3.5 h-3.5"/>
</button> </button>
+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 { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
function getFirstArtist(artistString: string): string {
if (!artistString)
return artistString;
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
const parts = artistString.split(delimiters);
return parts[0].trim();
}
interface CheckFileExistenceRequest { interface CheckFileExistenceRequest {
spotify_id: string; spotify_id: string;
track_name: string; track_name: string;
@@ -28,8 +35,9 @@ interface FileExistenceResult {
track_name?: string; track_name?: string;
artist_name?: string; artist_name?: string;
} }
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks); const CheckFilesExistence = (outputDir: string, rootDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, rootDir, tracks);
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath); const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
const CreateM3U8File = (playlistName: string, outputDir: string, filePaths: string[]): Promise<void> => (window as any)["go"]["main"]["App"]["CreateM3U8File"](playlistName, outputDir, filePaths);
export function useDownload(region: string) { export function useDownload(region: string) {
const [downloadProgress, setDownloadProgress] = useState<number>(0); const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
@@ -74,10 +82,16 @@ export function useDownload(region: string) {
if (hasSubfolder) { if (hasSubfolder) {
useAlbumTrackNumber = true; useAlbumTrackNumber = true;
} }
const displayArtist = settings.useFirstArtistOnly && artistName
? getFirstArtist(artistName)
: artistName;
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
? getFirstArtist(albumArtist)
: albumArtist;
const templateData: TemplateData = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: displayArtist?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: trackNumberForTemplate, track: trackNumberForTemplate,
year: yearValue, year: yearValue,
@@ -85,7 +99,7 @@ export function useDownload(region: string) {
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && !useAlbumSubfolder) { if (settings.createPlaylistFolder && playlistName && !useAlbumSubfolder) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -105,9 +119,9 @@ export function useDownload(region: string) {
const checkRequest: CheckFileExistenceRequest = { const checkRequest: CheckFileExistenceRequest = {
spotify_id: spotifyId || isrc, spotify_id: spotifyId || isrc,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: displayArtist || "",
album_name: albumName, album_name: albumName,
album_artist: albumArtist, album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate, release_date: finalReleaseDate || releaseDate,
track_number: finalTrackNumber || spotifyTrackNumber || 0, track_number: finalTrackNumber || spotifyTrackNumber || 0,
disc_number: spotifyDiscNumber || 0, disc_number: spotifyDiscNumber || 0,
@@ -117,7 +131,7 @@ export function useDownload(region: string) {
include_track_number: settings.trackNumber || false, include_track_number: settings.trackNumber || false,
audio_format: serviceForCheck, audio_format: serviceForCheck,
}; };
const existenceResults = await CheckFilesExistence(outputDir, [checkRequest]); const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, [checkRequest]);
if (existenceResults.length > 0 && existenceResults[0].exists) { if (existenceResults.length > 0 && existenceResults[0].exists) {
fileExists = true; fileExists = true;
return { return {
@@ -135,7 +149,7 @@ export function useDownload(region: string) {
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
let itemID: string | undefined; let itemID: string | undefined;
if (!fileExists) { if (!fileExists) {
itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || ""); itemID = await AddToDownloadQueue(isrc, trackName || "", displayArtist || "", albumName || "");
} }
if (service === "auto") { if (service === "auto") {
let streamingURLs: any = null; let streamingURLs: any = null;
@@ -375,7 +389,7 @@ export function useDownload(region: string) {
}; };
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (folderName && (!isAlbum || !useAlbumSubfolder)) { if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -628,7 +642,7 @@ export function useDownload(region: string) {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}"); const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) { if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
const selectedTrackObjects = selectedTracks const selectedTrackObjects = selectedTracks
@@ -654,13 +668,15 @@ export function useDownload(region: string) {
audio_format: audioFormat, audio_format: audioFormat,
}; };
}); });
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
const existingSpotifyIDs = new Set<string>(); const existingSpotifyIDs = new Set<string>();
const existingFilePaths = new Map<string, string>(); const existingFilePaths = new Map<string, string>();
const finalFilePaths = new Map<string, string>();
for (const result of existenceResults) { for (const result of existenceResults) {
if (result.exists) { if (result.exists) {
existingSpotifyIDs.add(result.spotify_id); existingSpotifyIDs.add(result.spotify_id);
existingFilePaths.set(result.spotify_id, result.file_path || ""); existingFilePaths.set(result.spotify_id, result.file_path || "");
finalFilePaths.set(result.spotify_id, result.file_path || "");
} }
} }
logger.info(`found ${existingSpotifyIDs.size} existing files`); logger.info(`found ${existingSpotifyIDs.size} existing files`);
@@ -711,6 +727,10 @@ export function useDownload(region: string) {
successCount++; successCount++;
logger.success(`downloaded: ${track.name} - ${track.artists}`); logger.success(`downloaded: ${track.name} - ${track.artists}`);
} }
if (response.file) {
finalFilePaths.set(isrc, response.file);
finalFilePaths.set(track.spotify_id || isrc, response.file);
}
setDownloadedTracks((prev) => new Set(prev).add(isrc)); setDownloadedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => { setFailedTracks((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -743,6 +763,20 @@ export function useDownload(region: string) {
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App"); const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems(); await CancelAllQueuedItems();
if (settings.createM3u8File && folderName) {
const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || t.isrc) || "").filter((p) => p !== "");
if (paths.length > 0) {
try {
logger.info(`creating m3u8 playlist: ${folderName}`);
await CreateM3U8File(folderName, outputDir, paths);
toast.success("M3U8 playlist created");
}
catch (err) {
logger.error(`failed to create m3u8 playlist: ${err}`);
toast.error(`Failed to create M3U8 playlist: ${err}`);
}
}
}
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`); toast.success(`Downloaded ${successCount} tracks successfully`);
@@ -777,7 +811,7 @@ export function useDownload(region: string) {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}"); const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) { if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
logger.info(`checking existing files in parallel...`); logger.info(`checking existing files in parallel...`);
@@ -800,13 +834,16 @@ export function useDownload(region: string) {
audio_format: audioFormat, audio_format: audioFormat,
}; };
}); });
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
const finalFilePaths: string[] = new Array(tracksWithIsrc.length).fill("");
const existingSpotifyIDs = new Set<string>(); const existingSpotifyIDs = new Set<string>();
const existingFilePaths = new Map<string, string>(); const existingFilePaths = new Map<string, string>();
for (const result of existenceResults) { for (let i = 0; i < existenceResults.length; i++) {
const result = existenceResults[i];
if (result.exists) { if (result.exists) {
existingSpotifyIDs.add(result.spotify_id); existingSpotifyIDs.add(result.spotify_id);
existingFilePaths.set(result.spotify_id, result.file_path || ""); existingFilePaths.set(result.spotify_id, result.file_path || "");
finalFilePaths[i] = result.file_path || "";
} }
} }
logger.info(`found ${existingSpotifyIDs.size} existing files`); logger.info(`found ${existingSpotifyIDs.size} existing files`);
@@ -861,6 +898,9 @@ export function useDownload(region: string) {
newSet.delete(track.isrc); newSet.delete(track.isrc);
return newSet; return newSet;
}); });
if (response.file) {
finalFilePaths[originalIndex] = response.file;
}
} }
else { else {
errorCount++; errorCount++;
@@ -885,6 +925,17 @@ export function useDownload(region: string) {
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App"); const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued(); await CancelQueued();
if (settings.createM3u8File && folderName) {
try {
logger.info(`creating m3u8 playlist: ${folderName}`);
await CreateM3U8File(folderName, outputDir, finalFilePaths.filter(p => p !== ""));
toast.success("M3U8 playlist created");
}
catch (err) {
logger.error(`failed to create m3u8 playlist: ${err}`);
toast.error(`Failed to create M3U8 playlist: ${err}`);
}
}
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`); toast.success(`Downloaded ${successCount} tracks successfully`);
+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"; autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
autoQuality: "16" | "24"; autoQuality: "16" | "24";
allowFallback: boolean; allowFallback: boolean;
useSpotFetchAPI: boolean;
spotFetchAPIUrl: string;
createPlaylistFolder: boolean;
createM3u8File: boolean;
useFirstArtistOnly: boolean;
} }
export const FOLDER_PRESETS: Record<FolderPreset, { export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
@@ -101,7 +106,12 @@ export const DEFAULT_SETTINGS: Settings = {
amazonQuality: "original", amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon", autoOrder: "tidal-qobuz-amazon",
autoQuality: "16", autoQuality: "16",
allowFallback: true allowFallback: true,
useSpotFetchAPI: false,
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
createPlaylistFolder: true,
createM3u8File: false,
useFirstArtistOnly: false
}; };
export const FONT_OPTIONS: { export const FONT_OPTIONS: {
value: FontFamily; value: FontFamily;
@@ -290,6 +300,15 @@ export async function loadSettings(): Promise<Settings> {
if (!('allowFallback' in parsed)) { if (!('allowFallback' in parsed)) {
parsed.allowFallback = true; parsed.allowFallback = true;
} }
if (!('createPlaylistFolder' in parsed)) {
parsed.createPlaylistFolder = true;
}
if (!('createM3u8File' in parsed)) {
parsed.createM3u8File = false;
}
if (!('useFirstArtistOnly' in parsed)) {
parsed.useFirstArtistOnly = false;
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!; return cachedSettings!;
} }
+1 -1
View File
@@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function sanitizePath(input: string, os: string): string { export function sanitizePath(input: string, os: string): string {
let sanitized = input.trim(); const sanitized = input.trim();
if (os === "Windows") { if (os === "Windows") {
return sanitized.replace(/[<>:"/\\|?*]/g, "_"); return sanitized.replace(/[<>:"/\\|?*]/g, "_");
} }
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.0.7", "productVersion": "7.0.8",
"copyright": "© 2026 afkarxyz" "copyright": "© 2026 afkarxyz"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",