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

Support Our Work

+

+ If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going. +

+
+ +
+ + + +
+
)} ); } diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx index 98f6533..3b8e31a 100644 --- a/frontend/src/components/AudioConverterPage.tsx +++ b/frontend/src/components/AudioConverterPage.tsx @@ -243,7 +243,7 @@ export function AudioConverterPage() { codec: outputFormat === "m4a" ? m4aCodec : "", }); setFiles((prev) => prev.map((f) => { - const result = results.find((r) => r.input_file === f.path); + const result = results.find((r) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase()); if (result) { return { ...f, diff --git a/frontend/src/components/DebugLoggerPage.tsx b/frontend/src/components/DebugLoggerPage.tsx index 7162d14..4b94fc8 100644 --- a/frontend/src/components/DebugLoggerPage.tsx +++ b/frontend/src/components/DebugLoggerPage.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef } from "react"; -import { Trash2, Copy, Check } from "lucide-react"; +import { Trash2, Copy, Check, FileDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { logger, type LogEntry } from "@/lib/logger"; +import { ExportFailedDownloads } from "../../wailsjs/go/main/App"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; const levelColors: Record = { info: "text-blue-500", success: "text-green-500", @@ -51,10 +53,29 @@ export function DebugLoggerPage() { console.error("Failed to copy logs:", err); } }; + const handleExportFailed = async () => { + try { + const message = await ExportFailedDownloads(); + if (message.startsWith("Successfully")) { + toast.success(message); + } + else if (message !== "Export cancelled") { + toast.info(message); + } + } + catch (error) { + console.error("Failed to export:", error); + toast.error(`Failed to export: ${error}`); + } + }; return (

Debug Logs

+ )} + {queueInfo.failed_count > 0 && ()} @@ -123,22 +151,22 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
-
+
toggleFilter('queued')}> Queued: {queueInfo.queued_count}
-
+
toggleFilter('completed')}> Completed: {queueInfo.completed_count}
-
+
toggleFilter('skipped')}> Skipped: {queueInfo.skipped_count}
-
+
toggleFilter('failed')}> Failed: {queueInfo.failed_count} @@ -180,7 +208,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { {queueInfo.queue.length === 0 ? (

No downloads in queue

-
) : (queueInfo.queue.map((item) => (
+
) : filteredQueue.length === 0 ? (
+

No downloads with status "{filterStatus}"

+ +
) : (filteredQueue.map((item: any) => (
{getStatusIcon(item.status)}
diff --git a/frontend/src/components/HistoryPage.tsx b/frontend/src/components/HistoryPage.tsx index d08937e..1c4274c 100644 --- a/frontend/src/components/HistoryPage.tsx +++ b/frontend/src/components/HistoryPage.tsx @@ -130,8 +130,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) { } }); setFilteredDownloadHistory(result); - setDownloadCurrentPage(1); }, [downloadHistory, downloadSearchQuery, downloadSortBy]); + useEffect(() => { + setDownloadCurrentPage(1); + }, [downloadSearchQuery, downloadSortBy]); useEffect(() => { let result = [...fetchHistory]; if (activeFetchTab !== "all") { @@ -144,8 +146,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) { } result.sort((a, b) => b.timestamp - a.timestamp); setFilteredFetchHistory(result); - setFetchCurrentPage(1); }, [fetchHistory, fetchSearchQuery, activeFetchTab]); + useEffect(() => { + setFetchCurrentPage(1); + }, [fetchSearchQuery, activeFetchTab]); const handlePreview = async (id: string, spotifyId: string) => { if (playingPreviewId === id) { audioRef.current?.pause(); diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 9442865..75e2c52 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -4,47 +4,47 @@ import { Button } from "@/components/ui/button"; import { InputWithContext } from "@/components/ui/input-with-context"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { FolderOpen, Save, RotateCcw, Info, ArrowRight } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { FolderOpen, Save, RotateCcw, Info, ArrowRight, Settings, FolderCog, } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; -import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; +import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings"; import { themes, applyTheme } from "@/lib/themes"; import { SelectFolder } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; const TidalIcon = ({ className }: { className?: string; }) => ( - - -); + + + ); const QobuzIcon = ({ className }: { className?: string; }) => ( - - -); + + + ); const AmazonIcon = ({ className }: { className?: string; }) => ( - - -); + + + ); interface SettingsPageProps { onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onResetRequest?: (resetFn: () => void) => void; } -export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) { +export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) { const [savedSettings, setSavedSettings] = useState(getSettings()); const [tempSettings, setTempSettings] = useState(savedSettings); - const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); + const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark")); const [showResetConfirm, setShowResetConfirm] = useState(false); const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); const resetToSaved = useCallback(() => { const freshSavedSettings = getSettings(); flushSync(() => { setTempSettings(freshSavedSettings); - setIsDark(document.documentElement.classList.contains('dark')); + setIsDark(document.documentElement.classList.contains("dark")); }); }, []); useEffect(() => { @@ -73,7 +73,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting applyTheme(tempSettings.theme); applyFont(tempSettings.fontFamily); setTimeout(() => { - setIsDark(document.documentElement.classList.contains('dark')); + setIsDark(document.documentElement.classList.contains("dark")); }, 0); }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]); useEffect(() => { @@ -124,315 +124,496 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting const handleAutoQualityChange = async (value: "16" | "24") => { setTempSettings((prev) => ({ ...prev, autoQuality: value })); }; - return (
-

Settings

- -
- -
- -
- -
- setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/> - -
-
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/> + const [activeTab, setActiveTab] = useState<"general" | "files">("general"); + return (
+
+

Settings

+
+ +
+
+ + +
-
+
+ {activeTab === "general" && (
+
+
+ +
+ setTempSettings((prev) => ({ + ...prev, + downloadPath: e.target.value, + }))} placeholder="C:\Users\YourUsername\Music"/> + +
+
-
- -
- +
+ + +
- {tempSettings.downloader === "auto" && (<> - +
+ + +
- - )} +
+ + +
- {tempSettings.downloader === "tidal" && ()} +
+ setTempSettings((prev) => ({ + ...prev, + sfxEnabled: checked, + }))}/> + +
+
- {tempSettings.downloader === "qobuz" && ()} +
+
+ +
+ - {tempSettings.downloader === "amazon" && (
- 16-bit/44.1kHz / 24-bit/48kHz -
)} -
-
+ {tempSettings.downloader === "auto" && (<> + - - {((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") || - (tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "7") || - (tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (
-
- setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/> - -
+ + )} + + {tempSettings.downloader === "tidal" && ()} + + {tempSettings.downloader === "qobuz" && ()} + + {tempSettings.downloader === "amazon" && (
+ 16-bit - 24-bit/44.1kHz - 192kHz +
)} +
+ + {((tempSettings.downloader === "tidal" && + tempSettings.tidalQuality === "HI_RES_LOSSLESS") || + (tempSettings.downloader === "qobuz" && + tempSettings.qobuzQuality === "7") || + (tempSettings.downloader === "auto" && + tempSettings.autoQuality === "24")) && (
+
+ setTempSettings((prev) => ({ + ...prev, + allowFallback: checked, + }))}/> + +
+
)} +
+ +
+ +
+
+ setTempSettings((prev) => ({ + ...prev, + embedLyrics: checked, + }))}/> + +
+
+ setTempSettings((prev) => ({ + ...prev, + embedMaxQualityCover: checked, + }))}/> + +
+
+
)} + {activeTab === "files" && (
+
+
+
+ + + + + + +

+ Variables:{" "} + {TEMPLATE_VARIABLES.map((v) => v.key).join(", ")} +

+
+
+
+
+ + {tempSettings.folderPreset === "custom" && ( setTempSettings((prev) => ({ + ...prev, + folderTemplate: e.target.value, + }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)} +
+ {tempSettings.folderTemplate && (

+ Preview:{" "} + + {tempSettings.folderTemplate + .replace(/\{artist\}/g, "Kendrick Lamar, SZA") + .replace(/\{album\}/g, "Black Panther") + .replace(/\{album_artist\}/g, "Kendrick Lamar") + .replace(/\{year\}/g, "2018")} + / + +

)} +
-
-
- - setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/> -
-
- - setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/> -
-
- -
- - -
-
- - - - - - -

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

-
-
-
-
- - {tempSettings.folderPreset === "custom" && ( setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)} -
- {tempSettings.folderTemplate && (

- Preview: {tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/ -

)} -
+ createPlaylistFolder: checked, + }))}/> + +
-
- - -
-
- - - - - - -

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

-
-
-
-
- - {tempSettings.filenamePreset === "custom" && ( setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} -
- {tempSettings.filenameTemplate && (

- Preview: {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 -

)} -
+ createM3u8File: checked, + }))}/> + +
+ +
+ setTempSettings((prev) => ({ + ...prev, + useFirstArtistOnly: checked, + }))}/> + +
+
+ +
+
+ + + + + + +

+ Variables:{" "} + {TEMPLATE_VARIABLES.map((v) => v.key).join(", ")} +

+
+
+
+
+ + {tempSettings.filenamePreset === "custom" && ( setTempSettings((prev) => ({ + ...prev, + filenameTemplate: e.target.value, + }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} +
+ {tempSettings.filenameTemplate && (

+ Preview:{" "} + + {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 + +

)} +
+
)}
-
- -
- - -
- - - - - - Reset to Default? - - This will reset all settings to their default values. Your custom configurations will be lost. - - - - - - - - - - -
); + + + + Reset to Default? + + This will reset all settings to their default values. Your custom + configurations will be lost. + + + + + + + + +
); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 332bdbd..782c63b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -10,6 +10,9 @@ import { BadgeAlertIcon } from "@/components/ui/badge-alert"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; +import BmcLogo from "@/assets/bmc-logo-side.svg"; +import BmcLogoWhite from "@/assets/bmc-logo-side-white.svg"; +import KofiLogo from "@/assets/kofi_symbol.svg"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history"; interface SidebarProps { currentPage: PageType; @@ -109,16 +112,26 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {

About

- - - - - -

Every coffee helps me keep going

-
-
+ + +
+ +
+ + +
+
); } diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index acc0471..0c55727 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -1,6 +1,22 @@ -import { X, Minus, Maximize } from "lucide-react"; +import { X, Minus, Maximize, Settings, Info } from "lucide-react"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; +import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { getSettings, updateSettings } from "@/lib/settings"; +import { useState, useEffect } from "react"; export function TitleBar() { + const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false); + useEffect(() => { + const settings = getSettings(); + if (settings) { + setUseSpotFetchAPI(settings.useSpotFetchAPI || false); + } + }, []); + const handleSpotFetchAPIToggle = () => { + const newValue = !useSpotFetchAPI; + setUseSpotFetchAPI(newValue); + updateSettings({ useSpotFetchAPI: newValue }); + }; const handleMinimize = () => { WindowMinimise(); }; @@ -11,11 +27,39 @@ export function TitleBar() { Quit(); }; return (<> - +
- - -
+ + +
+ + + + + + +
+ SpotFetch API + + + + + + +

Spotify Blocked Countries:

+

Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen

+
+
+
+
+ + + Use SpotFetch API + {useSpotFetchAPI ? "✓" : ""} + +
+
+
diff --git a/frontend/src/components/ui/menubar.tsx b/frontend/src/components/ui/menubar.tsx new file mode 100644 index 0000000..2dfd7f2 --- /dev/null +++ b/frontend/src/components/ui/menubar.tsx @@ -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.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); +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.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; +const MenubarSubTrigger = React.forwardRef, React.ComponentPropsWithoutRef & { + inset?: boolean; +}>(({ className, inset, children, ...props }, ref) => ( + {children} + + )); +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; +const MenubarSubContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; +const MenubarContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => ( + + )); +MenubarContent.displayName = MenubarPrimitive.Content.displayName; +const MenubarItem = React.forwardRef, React.ComponentPropsWithoutRef & { + inset?: boolean; +}>(({ className, inset, ...props }, ref) => ()); +MenubarItem.displayName = MenubarPrimitive.Item.displayName; +const MenubarCheckboxItem = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, checked, ...props }, ref) => ( + + + + + + {children} + )); +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; +const MenubarRadioItem = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => ( + + + + + + {children} + )); +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; +const MenubarLabel = React.forwardRef, React.ComponentPropsWithoutRef & { + inset?: boolean; +}>(({ className, inset, ...props }, ref) => ()); +MenubarLabel.displayName = MenubarPrimitive.Label.displayName; +const MenubarSeparator = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; +const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return (); +}; +MenubarShortcut.displayname = "MenubarShortcut"; +export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarSeparator, MenubarLabel, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarPortal, MenubarSubContent, MenubarSubTrigger, MenubarSub, MenubarGroup, MenubarShortcut, }; diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 2614dc9..f302c63 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -5,6 +5,13 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath } from "@/lib/utils"; import { logger } from "@/lib/logger"; import type { TrackMetadata } from "@/types/api"; +function getFirstArtist(artistString: string): string { + if (!artistString) + return artistString; + const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i; + const parts = artistString.split(delimiters); + return parts[0].trim(); +} interface CheckFileExistenceRequest { spotify_id: string; track_name: string; @@ -28,8 +35,9 @@ interface FileExistenceResult { track_name?: string; artist_name?: string; } -const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks); +const CheckFilesExistence = (outputDir: string, rootDir: string, tracks: CheckFileExistenceRequest[]): Promise => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, rootDir, tracks); const SkipDownloadItem = (itemID: string, filePath: string): Promise => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath); +const CreateM3U8File = (playlistName: string, outputDir: string, filePaths: string[]): Promise => (window as any)["go"]["main"]["App"]["CreateM3U8File"](playlistName, outputDir, filePaths); export function useDownload(region: string) { const [downloadProgress, setDownloadProgress] = useState(0); const [isDownloading, setIsDownloading] = useState(false); @@ -74,10 +82,16 @@ export function useDownload(region: string) { if (hasSubfolder) { useAlbumTrackNumber = true; } + const displayArtist = settings.useFirstArtistOnly && artistName + ? getFirstArtist(artistName) + : artistName; + const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist + ? getFirstArtist(albumArtist) + : albumArtist; const templateData: TemplateData = { - artist: artistName?.replace(/\//g, placeholder), + artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), - album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder), + album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, @@ -85,7 +99,7 @@ export function useDownload(region: string) { }; const folderTemplate = settings.folderTemplate || ""; const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); - if (playlistName && !useAlbumSubfolder) { + if (settings.createPlaylistFolder && playlistName && !useAlbumSubfolder) { outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { @@ -105,9 +119,9 @@ export function useDownload(region: string) { const checkRequest: CheckFileExistenceRequest = { spotify_id: spotifyId || isrc, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist || "", album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, track_number: finalTrackNumber || spotifyTrackNumber || 0, disc_number: spotifyDiscNumber || 0, @@ -117,7 +131,7 @@ export function useDownload(region: string) { include_track_number: settings.trackNumber || false, audio_format: serviceForCheck, }; - const existenceResults = await CheckFilesExistence(outputDir, [checkRequest]); + const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, [checkRequest]); if (existenceResults.length > 0 && existenceResults[0].exists) { fileExists = true; return { @@ -135,7 +149,7 @@ export function useDownload(region: string) { const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); let itemID: string | undefined; if (!fileExists) { - itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || ""); + itemID = await AddToDownloadQueue(isrc, trackName || "", displayArtist || "", albumName || ""); } if (service === "auto") { let streamingURLs: any = null; @@ -375,7 +389,7 @@ export function useDownload(region: string) { }; const folderTemplate = settings.folderTemplate || ""; const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); - if (folderName && (!isAlbum || !useAlbumSubfolder)) { + if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) { outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { @@ -628,7 +642,7 @@ export function useDownload(region: string) { let outputDir = settings.downloadPath; const os = settings.operatingSystem; const useAlbumTag = settings.folderTemplate?.includes("{album}"); - if (folderName && (!isAlbum || !useAlbumTag)) { + if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumTag)) { outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); } const selectedTrackObjects = selectedTracks @@ -654,13 +668,15 @@ export function useDownload(region: string) { audio_format: audioFormat, }; }); - const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); + const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks); const existingSpotifyIDs = new Set(); const existingFilePaths = new Map(); + const finalFilePaths = new Map(); for (const result of existenceResults) { if (result.exists) { existingSpotifyIDs.add(result.spotify_id); existingFilePaths.set(result.spotify_id, result.file_path || ""); + finalFilePaths.set(result.spotify_id, result.file_path || ""); } } logger.info(`found ${existingSpotifyIDs.size} existing files`); @@ -711,6 +727,10 @@ export function useDownload(region: string) { successCount++; logger.success(`downloaded: ${track.name} - ${track.artists}`); } + if (response.file) { + finalFilePaths.set(isrc, response.file); + finalFilePaths.set(track.spotify_id || isrc, response.file); + } setDownloadedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => { const newSet = new Set(prev); @@ -743,6 +763,20 @@ export function useDownload(region: string) { shouldStopDownloadRef.current = false; const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App"); await CancelAllQueuedItems(); + if (settings.createM3u8File && folderName) { + const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || t.isrc) || "").filter((p) => p !== ""); + if (paths.length > 0) { + try { + logger.info(`creating m3u8 playlist: ${folderName}`); + await CreateM3U8File(folderName, outputDir, paths); + toast.success("M3U8 playlist created"); + } + catch (err) { + logger.error(`failed to create m3u8 playlist: ${err}`); + toast.error(`Failed to create M3U8 playlist: ${err}`); + } + } + } logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); if (errorCount === 0 && skippedCount === 0) { toast.success(`Downloaded ${successCount} tracks successfully`); @@ -777,7 +811,7 @@ export function useDownload(region: string) { let outputDir = settings.downloadPath; const os = settings.operatingSystem; const useAlbumTag = settings.folderTemplate?.includes("{album}"); - if (folderName && (!isAlbum || !useAlbumTag)) { + if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumTag)) { outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); } logger.info(`checking existing files in parallel...`); @@ -800,13 +834,16 @@ export function useDownload(region: string) { audio_format: audioFormat, }; }); - const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); + const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks); + const finalFilePaths: string[] = new Array(tracksWithIsrc.length).fill(""); const existingSpotifyIDs = new Set(); const existingFilePaths = new Map(); - for (const result of existenceResults) { + for (let i = 0; i < existenceResults.length; i++) { + const result = existenceResults[i]; if (result.exists) { existingSpotifyIDs.add(result.spotify_id); existingFilePaths.set(result.spotify_id, result.file_path || ""); + finalFilePaths[i] = result.file_path || ""; } } logger.info(`found ${existingSpotifyIDs.size} existing files`); @@ -861,6 +898,9 @@ export function useDownload(region: string) { newSet.delete(track.isrc); return newSet; }); + if (response.file) { + finalFilePaths[originalIndex] = response.file; + } } else { errorCount++; @@ -885,6 +925,17 @@ export function useDownload(region: string) { shouldStopDownloadRef.current = false; const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App"); await CancelQueued(); + if (settings.createM3u8File && folderName) { + try { + logger.info(`creating m3u8 playlist: ${folderName}`); + await CreateM3U8File(folderName, outputDir, finalFilePaths.filter(p => p !== "")); + toast.success("M3U8 playlist created"); + } + catch (err) { + logger.error(`failed to create m3u8 playlist: ${err}`); + toast.error(`Failed to create M3U8 playlist: ${err}`); + } + } logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); if (errorCount === 0 && skippedCount === 0) { toast.success(`Downloaded ${successCount} tracks successfully`); diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 9d9d61d..58b5a5d 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -26,6 +26,11 @@ export interface Settings { autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz"; autoQuality: "16" | "24"; allowFallback: boolean; + useSpotFetchAPI: boolean; + spotFetchAPIUrl: string; + createPlaylistFolder: boolean; + createM3u8File: boolean; + useFirstArtistOnly: boolean; } export const FOLDER_PRESETS: Record { if (!('allowFallback' in parsed)) { parsed.allowFallback = true; } + if (!('createPlaylistFolder' in parsed)) { + parsed.createPlaylistFolder = true; + } + if (!('createM3u8File' in parsed)) { + parsed.createM3u8File = false; + } + if (!('useFirstArtistOnly' in parsed)) { + parsed.useFirstArtistOnly = false; + } cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; return cachedSettings!; } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 25d4548..5537671 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export function sanitizePath(input: string, os: string): string { - let sanitized = input.trim(); + const sanitized = input.trim(); if (os === "Windows") { return sanitized.replace(/[<>:"/\\|?*]/g, "_"); } diff --git a/wails.json b/wails.json index 77fdc2e..87aede8 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.0.7", + "productVersion": "7.0.8", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",