Compare commits

...

31 Commits

Author SHA1 Message Date
afkarxyz 3a5aea4c91 v6.2 2025-11-25 13:19:11 +07:00
afkarxyz 6b4ad16882 v6.2 2025-11-25 13:15:43 +07:00
afkarxyz 4dbd88e689 v6.1 2025-11-24 15:19:25 +07:00
afkarxyz d0665fdcc5 v6.1 2025-11-24 15:15:35 +07:00
afkarxyz 6ee3c2f653 v6.1 2025-11-24 14:52:47 +07:00
afkarxyz 73d8205f6f v6.0 2025-11-24 05:29:06 +07:00
afkarxyz 17fe37fbb7 v6.0 2025-11-24 05:22:04 +07:00
afkarxyz afe55db107 v6.0 2025-11-24 05:19:25 +07:00
afkarxyz 8a553774c6 v6.0 2025-11-24 05:15:24 +07:00
afkarxyz 869bf50330 SpotiDownloader 2025-11-24 03:35:26 +07:00
afkarxyz 1c03aaa92f v5.9 2025-11-23 10:40:27 +07:00
afkarxyz 1423a32528 v5.9 2025-11-23 10:32:53 +07:00
afkarxyz 633812faab v5.8 2025-11-23 05:45:30 +07:00
afkarxyz 355b68c8de v5.8 2025-11-23 05:36:11 +07:00
afkarxyz 884716069c v5.7-patch1-build4 2025-11-23 05:24:26 +07:00
afkarxyz 44658f6ba6 v5.7-patch1-build3 2025-11-23 05:11:51 +07:00
afkarxyz f9974d4a3e v5.7-patch1-build2 2025-11-23 05:09:02 +07:00
afkarxyz 840f26dd6f v5.7-patch1-build1 2025-11-23 05:05:15 +07:00
afkarxyz 5831a45839 v5.7-patch1 2025-11-23 04:58:45 +07:00
afkarxyz d1bd7da2de Update screenshot in README.md 2025-11-23 04:48:12 +07:00
afkarxyz 033980bbd2 Add 'jakarta.monochrome.tf' to tidal.json 2025-11-23 02:35:11 +07:00
afkarxyz 3ae039d7db v5.7 2025-11-22 18:08:34 +07:00
afkarxyz 436a98c606 Replace screenshot in README.md
Updated screenshot in the README file.
2025-11-22 18:06:24 +07:00
afkarxyz d90221b835 v5.7 2025-11-22 17:59:48 +07:00
afkarxyz 8a2dbe4e32 v5.7-beta3 2025-11-22 15:45:17 +07:00
afkarxyz ee2976143a v5.7-beta2 2025-11-22 12:06:00 +07:00
afkarxyz 1c9bba0140 Update screenshot in README.md 2025-11-22 12:03:50 +07:00
afkarxyz 10236f00c6 v5.7-beta1 2025-11-22 11:46:36 +07:00
Ahmed Alghafri a49bb560bd Add cross-platform path handling (#89)
Add cross-platform path handling support

- Add sanitizePath, joinPath, buildOutputPath utilities
- Add operatingSystem to Settings interface
- Replace hardcoded Windows paths with dynamic path handling
- Support Windows, Linux, and macOS
2025-11-22 10:36:25 +07:00
afkarxyz bb9e2dcbb6 v5.6 2025-11-22 07:42:41 +07:00
afkarxyz 7aeefc1fe5 v5.6
Added a download link to the README.
2025-11-22 07:42:21 +07:00
51 changed files with 5731 additions and 1439 deletions
+54 -24
View File
@@ -55,10 +55,11 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
@@ -77,13 +78,13 @@ jobs:
- name: Prepare artifacts
run: |
mkdir -p dist
Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC-${{ steps.version.outputs.version }}.exe"
Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-portable
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.exe
path: dist/SpotiFLAC.exe
retention-days: 7
build-macos:
@@ -123,10 +124,11 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
@@ -157,16 +159,16 @@ jobs:
--icon "SpotiFLAC.app" 175 120 \
--hide-extension "SpotiFLAC.app" \
--app-drop-link 425 120 \
"dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg" \
"dist/SpotiFLAC.dmg" \
"build/bin/SpotiFLAC.app" || \
# Fallback to hdiutil if create-dmg fails
hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg
hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-portable
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg
path: dist/SpotiFLAC.dmg
retention-days: 7
build-linux:
@@ -206,10 +208,11 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
@@ -245,36 +248,35 @@ jobs:
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
# Copy binary
cp build/bin/SpotiFLAC AppDir/usr/bin/spotiflac
cp build/bin/SpotiFLAC AppDir/usr/bin/
# Create desktop file
cat > AppDir/spotiflac.desktop << 'EOF'
[Desktop Entry]
Name=SpotiFLAC
Exec=spotiflac
Exec=SpotiFLAC
Icon=spotiflac
Type=Application
Categories=Audio;AudioVideo;
Comment=Get Spotify tracks in true FLAC from Tidal/Deezer
EOF
# Copy desktop file to usr/share/applications
cp AppDir/spotiflac.desktop AppDir/usr/share/applications/
# Use existing icon from build or convert SVG to PNG
# Create icon
if [ -f "build/appicon.png" ]; then
# Resize to 256x256 if needed
convert build/appicon.png -resize 256x256 AppDir/spotiflac.png
elif [ -f "frontend/public/icon.svg" ]; then
# Convert SVG to PNG
convert -background none -size 256x256 frontend/public/icon.svg AppDir/spotiflac.png
else
# Fallback: create simple icon
convert -size 256x256 radial-gradient:#FFD700-#FFA500 AppDir/spotiflac.png
echo "Warning: No icon found, building without icon"
fi
cp AppDir/spotiflac.png AppDir/usr/share/icons/hicolor/256x256/apps/
cp AppDir/spotiflac.png AppDir/.DirIcon
# Copy icon if exists
if [ -f "AppDir/spotiflac.png" ]; then
cp AppDir/spotiflac.png AppDir/usr/share/icons/hicolor/256x256/apps/
cp AppDir/spotiflac.png AppDir/.DirIcon
fi
# Create AppRun
cat > AppDir/AppRun << 'EOF'
@@ -283,19 +285,19 @@ jobs:
HERE=${SELF%/*}
export PATH="${HERE}/usr/bin/:${PATH}"
export LD_LIBRARY_PATH="${HERE}/usr/lib/:${LD_LIBRARY_PATH}"
exec "${HERE}/usr/bin/spotiflac" "$@"
exec "${HERE}/usr/bin/SpotiFLAC" "$@"
EOF
chmod +x AppDir/AppRun
# Create AppImage
mkdir -p dist
ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-portable
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
path: dist/SpotiFLAC.AppImage
retention-days: 7
create-release:
@@ -334,9 +336,37 @@ jobs:
## Downloads
- `SpotiFLAC-${{ steps.version.outputs.version }}.exe` - Windows
- `SpotiFLAC-${{ steps.version.outputs.version }}.dmg` - macOS
- `SpotiFLAC-${{ steps.version.outputs.version }}.AppImage` - Linux
- `SpotiFLAC.exe` - Windows
- `SpotiFLAC.dmg` - macOS
- `SpotiFLAC.AppImage` - Linux
<details>
<summary><b>Linux Requirements</b></summary>
The AppImage requires `webkit2gtk-4.1` to be installed on your system:
**Ubuntu/Debian:**
```bash
sudo apt install libwebkit2gtk-4.1-0
```
**Arch Linux:**
```bash
sudo pacman -S webkit2gtk-4.1
```
**Fedora:**
```bash
sudo dnf install webkit2gtk4.1
```
After installing the dependency, make the AppImage executable:
```bash
chmod +x SpotiFLAC.AppImage
./SpotiFLAC.AppImage
```
</details>
files: |
artifacts/windows-portable/*.exe
artifacts/macos-portable/*.dmg
+1
View File
@@ -58,3 +58,4 @@ temp/
# Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md
build.txt
+16 -3
View File
@@ -4,7 +4,8 @@
<div align="center">
Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
<br><br>
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=windows&logoColor=white)
@@ -13,14 +14,26 @@ Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
</div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
## Screenshot
![Image](https://github.com/user-attachments/assets/0e1338a6-244d-4e6b-9224-fd9549a585ed)
![Image](https://github.com/user-attachments/assets/adb2b0b4-7758-43fc-8f11-db49eec878db)
## Lossless Audio Check
A simple utility for verifying the authenticity of FLAC files.
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
#
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip)
## Other projects
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
+162 -25
View File
@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"spotiflac/backend"
"strings"
"time"
)
@@ -34,22 +37,51 @@ type SpotifyMetadataRequest struct {
// DownloadRequest represents the request structure for downloading tracks
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
Query string `json:"query,omitempty"`
ApiURL string `json:"api_url,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
TrackNumber bool `json:"track_number,omitempty"`
ISRC string `json:"isrc"`
Service string `json:"service"`
Query string `json:"query,omitempty"`
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
AlbumName string `json:"album_name,omitempty"`
ApiURL string `json:"api_url,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
TrackNumber bool `json:"track_number,omitempty"`
Position int `json:"position,omitempty"` // Position in playlist/album (1-based)
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
}
// DownloadResponse represents the response structure for download operations
type DownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
// GetStreamingURLs fetches all streaming URLs from song.link API
func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) {
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID)
client := backend.NewSongLinkClient()
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
jsonData, err := json.Marshal(urls)
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
// GetSpotifyMetadata fetches metadata from Spotify
@@ -110,24 +142,100 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
req.FilenameFormat = "title-artist"
}
if req.Service == "tidal" {
searchQuery := req.Query
if searchQuery == "" {
searchQuery = req.ISRC
// Early check: Check if file with same ISRC already exists
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
return DownloadResponse{
Success: true,
Message: "File with same ISRC already exists",
File: existingFile,
AlreadyExists: true,
}, nil
}
// Fallback: if we have track metadata, check if file already exists by filename
if req.TrackName != "" && req.ArtistName != "" {
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
return DownloadResponse{
Success: true,
Message: "File already exists",
File: expectedPath,
AlreadyExists: true,
}, nil
}
}
// Set downloading state
backend.SetDownloading(true)
defer backend.SetDownloading(false)
switch req.Service {
case "amazon":
downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" {
// Use provided URL directly
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Amazon Music",
}, fmt.Errorf("spotify ID is required for Amazon Music")
}
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
}
case "tidal":
if req.ApiURL == "" || req.ApiURL == "auto" {
downloader := backend.NewTidalDownloader("")
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber)
if req.ServiceURL != "" {
// Use provided URL directly with fallback to multiple APIs
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Tidal",
}, fmt.Errorf("spotify ID is required for Tidal")
}
filename, err = downloader.DownloadWithFallback(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
}
} else {
downloader := backend.NewTidalDownloader(req.ApiURL)
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber)
if req.ServiceURL != "" {
// Use provided URL directly with specific API
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Tidal",
}, fmt.Errorf("spotify ID is required for Tidal")
}
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
}
}
} else {
case "qobuz":
downloader := backend.NewQobuzDownloader()
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
default: // deezer
downloader := backend.NewDeezerDownloader()
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber)
if err == nil {
filename = "Downloaded via Deezer"
if req.ServiceURL != "" {
// Use provided URL directly
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Deezer",
}, fmt.Errorf("spotify ID is required for Deezer")
}
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
}
}
@@ -138,10 +246,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}, err
}
// Check if file already existed
alreadyExists := false
if strings.HasPrefix(filename, "EXISTS:") {
alreadyExists = true
filename = strings.TrimPrefix(filename, "EXISTS:")
}
message := "Download completed successfully"
if alreadyExists {
message = "File already exists"
}
return DownloadResponse{
Success: true,
Message: "Download completed successfully",
File: filename,
Success: true,
Message: message,
File: filename,
AlreadyExists: alreadyExists,
}, nil
}
@@ -159,9 +280,25 @@ func (a *App) OpenFolder(path string) error {
return nil
}
// SelectFolder opens a folder selection dialog and returns the selected path
func (a *App) SelectFolder(defaultPath string) (string, error) {
return backend.SelectFolderDialog(a.ctx, defaultPath)
}
// GetDefaults returns the default configuration
func (a *App) GetDefaults() map[string]string {
return map[string]string{
"downloadPath": backend.GetDefaultMusicPath(),
}
}
// GetDownloadProgress returns current download progress
func (a *App) GetDownloadProgress() backend.ProgressInfo {
return backend.GetDownloadProgress()
}
// Quit closes the application
func (a *App) Quit() {
// You can add cleanup logic here if needed
panic("quit") // This will trigger Wails to close the app
}
+432
View File
@@ -0,0 +1,432 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
type AmazonDownloader struct {
client *http.Client
regions []string
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
}
type SongLinkResponse struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
type DoubleDoubleSubmitResponse struct {
Success bool `json:"success"`
ID string `json:"id"`
}
type DoubleDoubleStatusResponse struct {
Status string `json:"status"`
FriendlyStatus string `json:"friendlyStatus"`
URL string `json:"url"`
Current struct {
Name string `json:"name"`
Artist string `json:"artist"`
} `json:"current"`
}
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: &http.Client{
Timeout: 120 * time.Second,
},
regions: []string{"us", "eu"},
apiCallResetTime: time.Now(),
}
}
func (a *AmazonDownloader) getRandomUserAgent() string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
rand.Intn(4)+11, rand.Intn(5)+4,
rand.Intn(7)+530, rand.Intn(7)+30,
rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60,
rand.Intn(7)+530, rand.Intn(6)+30)
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
// Reset counter every minute
now := time.Now()
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
a.apiCallCount = 0
a.apiCallResetTime = time.Now()
}
}
// Add delay between requests (6 seconds = 10 requests per minute)
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second // 7 seconds to be safe
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", a.getRandomUserAgent())
fmt.Println("Getting Amazon URL...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = a.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
// Update rate limit tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
if resp.StatusCode == 429 { // Too Many Requests
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp SongLinkResponse
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
if !ok || amazonLink.URL == "" {
return "", fmt.Errorf("amazon Music link not found")
}
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if strings.Contains(amazonURL, "trackAsin=") {
parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 {
trackAsin := strings.Split(parts[1], "&")[0]
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=")
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
}
}
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
return amazonURL, nil
}
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) {
var lastError error
for _, region := range a.regions {
fmt.Printf("\nTrying region: %s...\n", region)
// Decode base64 service URL
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
req, err := http.NewRequest("GET", submitURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create request: %w", err)
continue
}
req.Header.Set("User-Agent", a.getRandomUserAgent())
fmt.Println("Submitting download request...")
resp, err := a.client.Do(req)
if err != nil {
lastError = fmt.Errorf("failed to submit request: %w", err)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
continue
}
var submitResp DoubleDoubleSubmitResponse
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
resp.Body.Close()
lastError = fmt.Errorf("failed to decode submit response: %w", err)
continue
}
resp.Body.Close()
if !submitResp.Success || submitResp.ID == "" {
lastError = fmt.Errorf("submit request failed")
continue
}
downloadID := submitResp.ID
fmt.Printf("Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("Waiting for download to complete...")
maxWait := 300 * time.Second
elapsed := time.Duration(0)
pollInterval := 3 * time.Second
for elapsed < maxWait {
time.Sleep(pollInterval)
elapsed += pollInterval
statusReq, err := http.NewRequest("GET", statusURL, nil)
if err != nil {
continue
}
statusReq.Header.Set("User-Agent", a.getRandomUserAgent())
statusResp, err := a.client.Do(statusReq)
if err != nil {
fmt.Printf("\rStatus check failed, retrying...")
continue
}
if statusResp.StatusCode != 200 {
statusResp.Body.Close()
fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode)
continue
}
var status DoubleDoubleStatusResponse
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
statusResp.Body.Close()
fmt.Printf("\rInvalid JSON response, retrying...")
continue
}
statusResp.Body.Close()
if status.Status == "done" {
fmt.Println("\nDownload ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
} else if strings.HasPrefix(fileURL, "/") {
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
}
trackName := status.Current.Name
artist := status.Current.Artist
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
// Download file
downloadReq, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create download request: %w", err)
break
}
downloadReq.Header.Set("User-Agent", a.getRandomUserAgent())
fileResp, err := a.client.Do(downloadReq)
if err != nil {
lastError = fmt.Errorf("failed to download file: %w", err)
break
}
defer fileResp.Body.Close()
if fileResp.StatusCode != 200 {
lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode)
break
}
// Generate filename
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
for _, char := range `<>:"/\|?*` {
fileName = strings.ReplaceAll(fileName, string(char), "")
}
fileName = strings.TrimSpace(fileName)
filePath := filepath.Join(outputDir, fileName)
// Save file
out, err := os.Create(filePath)
if err != nil {
lastError = fmt.Errorf("failed to create file: %w", err)
break
}
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, fileResp.Body)
if err != nil {
out.Close()
return "", fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete!")
return filePath, nil
} else if status.Status == "error" {
errorMsg := status.FriendlyStatus
if errorMsg == "" {
errorMsg = "Unknown error"
}
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
// Still processing
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
}
fmt.Printf("\r%s...", friendlyStatus)
}
}
if elapsed >= maxWait {
lastError = fmt.Errorf("download timeout")
fmt.Printf("\nError with %s region: %v\n", region, lastError)
continue
}
if lastError != nil {
fmt.Printf("\nError with %s region: %v\n", region, lastError)
}
}
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Create output directory if needed
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + expectedPath, nil
}
}
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
// Download from service
filePath, err := a.DownloadFromService(amazonURL, outputDir)
if err != nil {
return "", err
}
// File already has embedded metadata, just rename if needed
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
// Build filename based on format settings
var newFilename string
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default: // "title-artist"
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
newFilename = newFilename + ".flac"
newFilePath := filepath.Join(outputDir, newFilename)
// Rename file
if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else {
filePath = newFilePath
fmt.Printf("Renamed to: %s\n", newFilename)
}
}
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil
}
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Amazon URL from Spotify track ID
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
+166 -43
View File
@@ -1,13 +1,13 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -56,8 +56,75 @@ func NewDeezerDownloader() *DeezerDownloader {
}
}
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc)
func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), spotifyURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL...")
resp, err := d.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) {
// Extract track ID from Deezer URL
// Format: https://www.deezer.com/track/3412534581
parts := strings.Split(deezerURL, "/track/")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid Deezer URL format")
}
// Get the track ID part and remove any query parameters
trackIDStr := strings.Split(parts[1], "?")[0]
trackIDStr = strings.TrimSpace(trackIDStr)
var trackID int64
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
if err != nil {
return 0, fmt.Errorf("failed to parse track ID: %w", err)
}
return trackID, nil
}
func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv")
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
resp, err := d.client.Get(url)
if err != nil {
@@ -75,14 +142,16 @@ func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
}
if track.ID == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
return nil, fmt.Errorf("track not found")
}
return &track, nil
}
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID)
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
resp, err := d.client.Get(url)
if err != nil {
@@ -119,11 +188,16 @@ func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
@@ -152,17 +226,7 @@ func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err
}
func sanitizeFilename(name string) string {
re := regexp.MustCompile(`[<>:"/\\|?*]`)
sanitized := re.ReplaceAllString(name, "_")
sanitized = strings.TrimSpace(sanitized)
if sanitized == "" {
return "Unknown"
}
return sanitized
}
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool) string {
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Build base filename based on format
@@ -176,52 +240,90 @@ func buildFilename(title, artist string, trackNumber int, format string, include
}
// Add track number prefix if enabled
if includeTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
if includeTrackNumber && position > 0 {
// Use album track number if in album folder structure, otherwise use playlist position
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
return filename + ".flac"
}
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool) error {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Using Deezer URL: %s\n", deezerURL)
track, err := d.GetTrackByISRC(isrc)
// Extract track ID from URL
trackID, err := d.GetTrackIDFromURL(deezerURL)
if err != nil {
return err
return "", err
}
artists := track.Artist.Name
if len(track.Contributors) > 0 {
var mainArtists []string
for _, contrib := range track.Contributors {
if contrib.Role == "Main" {
mainArtists = append(mainArtists, contrib.Name)
// Get track info by ID
track, err := d.GetTrackByID(trackID)
if err != nil {
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Artist.Name
if len(track.Contributors) > 0 {
var mainArtists []string
for _, contrib := range track.Contributors {
if contrib.Role == "Main" {
mainArtists = append(mainArtists, contrib.Name)
}
}
if len(mainArtists) > 0 {
artists = strings.Join(mainArtists, ", ")
}
}
if len(mainArtists) > 0 {
artists = strings.Join(mainArtists, ", ")
}
}
fmt.Printf("Found track: %s - %s\n", artists, track.Title)
fmt.Printf("Album: %s\n", track.Album.Title)
if trackTitle == "" {
trackTitle = track.Title
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
downloadURL, err := d.GetDownloadURL(track.ID)
if err != nil {
return err
return "", err
}
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(track.Title)
safeTitle := sanitizeFilename(trackTitle)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber)
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + filepath, nil
}
fmt.Println("Downloading FLAC file...")
if err := d.DownloadFile(downloadURL, filepath); err != nil {
return err
return "", err
}
fmt.Printf("Downloaded: %s\n", filepath)
@@ -238,20 +340,41 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
}
fmt.Println("Embedding metadata and cover art...")
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackPos > 0 {
trackNumberToEmbed = track.TrackPos
} else {
trackNumberToEmbed = position
}
}
metadata := Metadata{
Title: track.Title,
Title: trackTitle,
Artist: artists,
Album: track.Album.Title,
Album: albumTitle,
Date: track.ReleaseDate,
TrackNumber: track.TrackPos,
TrackNumber: trackNumberToEmbed,
DiscNumber: track.DiskNumber,
ISRC: track.ISRC,
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
return fmt.Errorf("failed to embed metadata: %w", err)
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
fmt.Println("Metadata embedded successfully!")
return nil
fmt.Println("✓ Downloaded successfully from Deezer")
return filepath, nil
}
func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Deezer URL from Spotify track ID
deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
+46
View File
@@ -0,0 +1,46 @@
package backend
import (
"fmt"
"regexp"
"strings"
)
// BuildExpectedFilename builds the expected filename based on track metadata and settings
func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
var filename string
// Build base filename based on format
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled
// Note: We can't determine the exact track number without fetching from API
// So we only add it if position > 0 (bulk download)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
return filename + ".flac"
}
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(name string) string {
re := regexp.MustCompile(`[<>:"/\\|?*]`)
sanitized := re.ReplaceAllString(name, "_")
sanitized = strings.TrimSpace(sanitized)
if sanitized == "" {
return "Unknown"
}
return sanitized
}
+27
View File
@@ -1,8 +1,11 @@
package backend
import (
"context"
"os/exec"
"runtime"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
func OpenFolderInExplorer(path string) error {
@@ -21,3 +24,27 @@ func OpenFolderInExplorer(path string) error {
return cmd.Start()
}
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
// If defaultPath is empty, use default music path
if defaultPath == "" {
defaultPath = GetDefaultMusicPath()
}
options := wailsRuntime.OpenDialogOptions{
Title: "Select Download Folder",
DefaultDirectory: defaultPath,
}
selectedPath, err := wailsRuntime.OpenDirectoryDialog(ctx, options)
if err != nil {
return "", err
}
// If user cancelled, selectedPath will be empty
if selectedPath == "" {
return "", nil
}
return selectedPath, nil
}
+71
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
@@ -111,3 +112,73 @@ func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// ReadISRCFromFile reads ISRC metadata from a FLAC file
func ReadISRCFromFile(filepath string) (string, error) {
if !fileExists(filepath) {
return "", fmt.Errorf("file does not exist")
}
f, err := flac.ParseFile(filepath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find VorbisComment block
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
// Get ISRC field
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
if err == nil && len(isrcValues) > 0 {
return isrcValues[0], nil
}
}
}
return "", nil // No ISRC found
}
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
if targetISRC == "" {
return "", false
}
// Read all .flac files in directory
entries, err := os.ReadDir(outputDir)
if err != nil {
return "", false
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// Check only .flac files
filename := entry.Name()
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
continue
}
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
// Read ISRC from file
isrc, err := ReadISRCFromFile(filepath)
if err != nil {
continue
}
// Compare ISRC (case-insensitive)
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
return filepath, true
}
}
return "", false
}
+135
View File
@@ -0,0 +1,135 @@
package backend
import (
"fmt"
"io"
"sync"
"time"
)
// Global progress tracker
var (
currentProgress float64
currentProgressLock sync.RWMutex
isDownloading bool
downloadingLock sync.RWMutex
currentSpeed float64
speedLock sync.RWMutex
)
// ProgressInfo represents download progress information
type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"`
}
// GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
downloading := isDownloading
downloadingLock.RUnlock()
currentProgressLock.RLock()
progress := currentProgress
currentProgressLock.RUnlock()
speedLock.RLock()
speed := currentSpeed
speedLock.RUnlock()
return ProgressInfo{
IsDownloading: downloading,
MBDownloaded: progress,
SpeedMBps: speed,
}
}
// SetDownloadSpeed updates the current download speed
func SetDownloadSpeed(mbps float64) {
speedLock.Lock()
currentSpeed = mbps
speedLock.Unlock()
}
// SetDownloadProgress updates the current download progress
func SetDownloadProgress(mbDownloaded float64) {
currentProgressLock.Lock()
currentProgress = mbDownloaded
currentProgressLock.Unlock()
}
// SetDownloading sets the downloading state
func SetDownloading(downloading bool) {
downloadingLock.Lock()
isDownloading = downloading
downloadingLock.Unlock()
if !downloading {
// Reset progress when download completes
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
}
// ProgressWriter wraps an io.Writer and reports download progress
type ProgressWriter struct {
writer io.Writer
total int64
lastPrinted int64
startTime int64
lastTime int64
lastBytes int64
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
now := getCurrentTimeMillis()
return &ProgressWriter{
writer: writer,
total: 0,
lastPrinted: 0,
startTime: now,
lastTime: now,
lastBytes: 0,
}
}
func getCurrentTimeMillis() int64 {
return time.Now().UnixMilli()
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.total += int64(n)
// Report progress every 256KB for smoother updates
if pw.total-pw.lastPrinted >= 256*1024 {
mbDownloaded := float64(pw.total) / (1024 * 1024)
// Calculate speed (MB/s)
now := getCurrentTimeMillis()
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
bytesDiff := float64(pw.total - pw.lastBytes)
if timeDiff > 0 {
speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps)
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
} else {
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
}
// Update global progress
SetDownloadProgress(mbDownloaded)
pw.lastPrinted = pw.total
pw.lastTime = now
pw.lastBytes = pw.total
}
return n, err
}
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
+384
View File
@@ -0,0 +1,384 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
type QobuzDownloader struct {
client *http.Client
appID string
}
type QobuzSearchResponse struct {
Query string `json:"query"`
Tracks struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
Duration int `json:"duration"`
TrackNumber int `json:"track_number"`
MediaNumber int `json:"media_number"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright"`
MaximumBitDepth int `json:"maximum_bit_depth"`
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
Hires bool `json:"hires"`
HiresStreamable bool `json:"hires_streamable"`
ReleaseDateOriginal string `json:"release_date_original"`
Performer struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"performer"`
Album struct {
Title string `json:"title"`
ID string `json:"id"`
Image struct {
Small string `json:"small"`
Thumbnail string `json:"thumbnail"`
Large string `json:"large"`
} `json:"image"`
Artist struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"artist"`
Label struct {
Name string `json:"name"`
} `json:"label"`
} `json:"album"`
}
type QobuzStreamResponse struct {
URL string `json:"url"`
}
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
appID: "798273057",
}
}
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
resp, err := q.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to search track: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var searchResp QobuzSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(searchResp.Tracks.Items) == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
}
return &searchResp.Tracks.Items[0], nil
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
// Map quality to Qobuz quality code
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
qualityCode := "27" // Default to Hi-Res
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
resp, err := q.client.Get(primaryURL)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Primary API response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
fmt.Printf("Got download URL from primary API\n")
return streamResp.URL, nil
}
}
if resp != nil {
resp.Body.Close()
}
// Fallback to secondary API
fmt.Println("Primary API failed, trying fallback...")
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
resp, err = q.client.Get(fallbackURL)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Fallback API error response: %s\n", string(body))
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Fallback API response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if streamResp.URL == "" {
return "", fmt.Errorf("no download URL available")
}
fmt.Printf("Got download URL from fallback API\n")
return streamResp.URL, nil
}
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
resp, err := q.client.Get(url)
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)
}
fmt.Printf("Creating file: %s\n", filepath)
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
resp, err := q.client.Get(coverURL)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create cover file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Build base filename based on format
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
// Use album track number if in album folder structure, otherwise use playlist position
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
// Create output directory if it doesn't exist
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(isrc)
if err != nil {
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Performer.Name
if track.Album.Artist.Name != "" {
artists = track.Album.Artist.Name
}
}
if trackTitle == "" {
trackTitle = track.Title
if track.Version != "" && track.Version != "null" {
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
}
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
qualityInfo := "Standard"
if track.Hires {
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
}
fmt.Printf("Quality: %s\n", qualityInfo)
fmt.Println("Getting download URL...")
downloadURL, err := q.GetDownloadURL(track.ID, quality)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
if downloadURL == "" {
return "", fmt.Errorf("received empty download URL")
}
// Show partial URL for security
urlPreview := downloadURL
if len(downloadURL) > 60 {
urlPreview = downloadURL[:60] + "..."
}
fmt.Printf("Download URL obtained: %s\n", urlPreview)
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + filepath, nil
}
fmt.Printf("Downloading FLAC file to: %s\n", filepath)
if err := q.DownloadFile(downloadURL, filepath); err != nil {
return "", fmt.Errorf("failed to download file: %w", err)
}
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.Image.Large != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
} else {
defer os.Remove(coverPath)
}
}
fmt.Println("Embedding metadata and cover art...")
releaseYear := ""
if len(track.ReleaseDateOriginal) >= 4 {
releaseYear = track.ReleaseDateOriginal[:4]
}
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackNumber > 0 {
trackNumberToEmbed = track.TrackNumber
} else {
trackNumberToEmbed = position
}
}
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: releaseYear,
TrackNumber: trackNumberToEmbed,
DiscNumber: track.MediaNumber,
ISRC: track.ISRC,
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
fmt.Println("Metadata embedded successfully!")
return filepath, nil
}
+150
View File
@@ -0,0 +1,150 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type SongLinkClient struct {
client *http.Client
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
}
type SongLinkURLs struct {
TidalURL string `json:"tidal_url"`
DeezerURL string `json:"deezer_url"`
AmazonURL string `json:"amazon_url"`
}
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
apiCallResetTime: time.Now(),
}
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting streaming URLs from song.link...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get URLs: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
urls := &SongLinkURLs{}
// Extract Tidal URL
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n")
}
// Extract Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
urls.DeezerURL = deezerLink.URL
fmt.Printf("✓ Deezer URL found\n")
}
// Extract Amazon URL
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n")
}
}
// Check if at least one URL was found
if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
return urls, nil
}
+85 -19
View File
@@ -29,7 +29,7 @@ const (
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
secretBytesRemotePath = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json"
secretBytesRemotePath = "https://cdn.jsdelivr.net/gh/afkarxyz/secretBytes@refs/heads/main/secrets/secretBytes.json"
)
var (
@@ -66,20 +66,34 @@ type TrackMetadata struct {
TrackNumber int `json:"track_number"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
SpotifyID string `json:"spotify_id,omitempty"`
}
// ArtistSimple holds basic artist info for clickable artists
type ArtistSimple struct {
ID string `json:"id"`
Name string `json:"name"`
ExternalURL string `json:"external_urls"`
}
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
type AlbumTrackMetadata struct {
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumType string `json:"album_type,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumType string `json:"album_type,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
ArtistURL string `json:"artist_url,omitempty"`
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
}
type TrackResponse struct {
@@ -93,6 +107,8 @@ type AlbumInfoMetadata struct {
Artists string `json:"artists"`
Images string `json:"images"`
Batch string `json:"batch,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
ArtistURL string `json:"artist_url,omitempty"`
}
type AlbumResponsePayload struct {
@@ -269,11 +285,6 @@ type artistResponse struct {
Popularity int `json:"popularity"`
}
type artistAlbumsResponse struct {
Items []albumSimplified `json:"items"`
Next string `json:"next"`
}
type playlistRaw struct {
Data playlistResponse
BatchEnabled bool
@@ -319,7 +330,7 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
return nil, err
}
return c.processSpotifyData(ctx, raw, parsed.Type)
return c.processSpotifyData(ctx, raw)
}
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) {
@@ -339,7 +350,7 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
}
}
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, dataType string) (interface{}, error) {
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
switch payload := raw.(type) {
case *playlistRaw:
return c.formatPlaylistData(payload), nil
@@ -477,6 +488,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
if item.Track == nil {
continue
}
var artistID, artistURL string
if len(item.Track.Artists) > 0 {
artistID = item.Track.Artists[0].ID
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", item.Track.Artists[0].ID)
}
artistsData := make([]ArtistSimple, 0, len(item.Track.Artists))
for _, a := range item.Track.Artists {
artistsData = append(artistsData, ArtistSimple{
ID: a.ID,
Name: a.Name,
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
})
}
tracks = append(tracks, AlbumTrackMetadata{
Artists: joinArtists(item.Track.Artists),
Name: item.Track.Name,
@@ -487,6 +511,12 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
TrackNumber: item.Track.TrackNumber,
ExternalURL: item.Track.ExternalURL.Spotify,
ISRC: item.Track.ExternalID.ISRC,
SpotifyID: item.Track.ID,
AlbumID: item.Track.Album.ID,
AlbumURL: item.Track.Album.ExternalURL.Spotify,
ArtistID: artistID,
ArtistURL: artistURL,
ArtistsData: artistsData,
})
}
@@ -498,12 +528,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) {
albumImage := firstImageURL(raw.Data.Images)
var artistID, artistURL string
if len(raw.Data.Artists) > 0 {
artistID = raw.Data.Artists[0].ID
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", raw.Data.Artists[0].ID)
}
info := AlbumInfoMetadata{
TotalTracks: raw.Data.TotalTracks,
Name: raw.Data.Name,
ReleaseDate: raw.Data.ReleaseDate,
Artists: joinArtists(raw.Data.Artists),
Images: albumImage,
ArtistID: artistID,
ArtistURL: artistURL,
}
if raw.BatchEnabled {
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
@@ -523,6 +560,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumR
TrackNumber: item.TrackNumber,
ExternalURL: item.ExternalURL.Spotify,
ISRC: isrc,
SpotifyID: item.ID,
})
}
@@ -577,6 +615,19 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
for _, tr := range tracks {
isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache)
var artistID, artistURL string
if len(tr.Artists) > 0 {
artistID = tr.Artists[0].ID
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", tr.Artists[0].ID)
}
artistsData := make([]ArtistSimple, 0, len(tr.Artists))
for _, a := range tr.Artists {
artistsData = append(artistsData, ArtistSimple{
ID: a.ID,
Name: a.Name,
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
})
}
allTracks = append(allTracks, AlbumTrackMetadata{
Artists: joinArtists(tr.Artists),
Name: tr.Name,
@@ -588,6 +639,12 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
TrackNumber: tr.TrackNumber,
ExternalURL: tr.ExternalURL.Spotify,
ISRC: isrc,
SpotifyID: tr.ID,
AlbumID: alb.ID,
AlbumURL: alb.ExternalURL.Spotify,
ArtistID: artistID,
ArtistURL: artistURL,
ArtistsData: artistsData,
})
}
}
@@ -628,6 +685,7 @@ func formatTrackData(raw *trackFull) TrackResponse {
TrackNumber: raw.TrackNumber,
ExternalURL: raw.ExternalURL.Spotify,
ISRC: raw.ExternalID.ISRC,
SpotifyID: raw.ID,
},
}
}
@@ -873,8 +931,16 @@ func (c *SpotifyMetadataClient) generateTOTP(ctx context.Context) (string, int64
}
func (c *SpotifyMetadataClient) fetchSecretBytes(ctx context.Context) ([]secretEntry, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, secretBytesRemotePath, nil)
// Add cache busting parameter with current timestamp
urlWithCacheBust := fmt.Sprintf("%s?t=%d", secretBytesRemotePath, time.Now().Unix())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlWithCacheBust, nil)
if err == nil {
// Add headers to bypass cache
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Expires", "0")
resp, err := c.httpClient.Do(req)
if err == nil {
body, readErr := io.ReadAll(resp.Body)
+250 -105
View File
@@ -22,13 +22,6 @@ type TidalDownloader struct {
apiURL string
}
type TidalSearchResponse struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
TotalNumberOfItems int `json:"totalNumberOfItems"`
Items []TidalTrack `json:"items"`
}
type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -68,6 +61,26 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
// If apiURL is empty, try to get first available API
if apiURL == "" {
downloader := &TidalDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
timeout: 30 * time.Second,
maxRetries: 3,
clientID: string(clientID),
clientSecret: string(clientSecret),
apiURL: "",
}
// Try to get available APIs
apis, err := downloader.GetAvailableAPIs()
if err == nil && len(apis) > 0 {
apiURL = apis[0] // Use first available API
}
}
return &TidalDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
@@ -81,7 +94,24 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
resp, err := http.Get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/tidal.json")
// Decode base64 API URL
apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==")
// Add cache-busting parameter with current timestamp
urlWithCacheBust := fmt.Sprintf("%s?t=%d", string(apiURL), time.Now().Unix())
// Create request with cache bypass headers
req, err := http.NewRequest("GET", urlWithCacheBust, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add headers to bypass cache
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Expires", "0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch API list: %w", err)
}
@@ -107,7 +137,9 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
func (t *TidalDownloader) GetAccessToken() (string, error) {
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
req, err := http.NewRequest("POST", "https://auth.tidal.com/v1/oauth2/token", strings.NewReader(data))
// Decode base64 API URL
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
if err != nil {
return "", err
}
@@ -136,16 +168,83 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
return result.AccessToken, nil
}
func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) {
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Tidal URL...")
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]
if !ok || tidalLink.URL == "" {
return "", fmt.Errorf("tidal link not found")
}
tidalURL := tidalLink.URL
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
return tidalURL, nil
}
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
// Extract track ID from Tidal URL
// Format: https://listen.tidal.com/track/441821360
// or: https://tidal.com/browse/track/123456789
parts := strings.Split(tidalURL, "/track/")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid tidal URL format")
}
// Get the track ID part and remove any query parameters
trackIDStr := strings.Split(parts[1], "?")[0]
trackIDStr = strings.TrimSpace(trackIDStr)
var trackID int64
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
if err != nil {
return 0, fmt.Errorf("failed to parse track ID: %w", err)
}
return trackID, nil
}
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
// URL encode the query parameter
searchURL := fmt.Sprintf("https://api.tidal.com/v1/search/tracks?query=%s&limit=25&offset=0&countryCode=US", url.QueryEscape(query))
// Decode base64 API URL
trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=")
trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID)
req, err := http.NewRequest("GET", searchURL, nil)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
return nil, err
}
@@ -160,112 +259,63 @@ func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, erro
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("failed to get track info: HTTP %d - %s", resp.StatusCode, string(body))
}
var result TidalSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
var trackInfo TidalTrack
if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil {
return nil, err
}
return &result, nil
}
func (t *TidalDownloader) GetTrackInfo(query, isrc string) (*TidalTrack, error) {
fmt.Printf("Fetching: %s", query)
if isrc != "" {
fmt.Printf(" (ISRC: %s)", isrc)
}
fmt.Println()
result, err := t.SearchTracks(query)
if err != nil {
return nil, err
}
if len(result.Items) == 0 {
return nil, fmt.Errorf("no tracks found for query: %s", query)
}
var selectedTrack *TidalTrack
if isrc != "" {
var isrcMatches []TidalTrack
for _, item := range result.Items {
if item.ISRC == isrc {
isrcMatches = append(isrcMatches, item)
}
}
if len(isrcMatches) > 1 {
for _, item := range isrcMatches {
for _, tag := range item.MediaMetadata.Tags {
if tag == "HIRES_LOSSLESS" {
selectedTrack = &item
break
}
}
if selectedTrack != nil {
break
}
}
if selectedTrack == nil {
selectedTrack = &isrcMatches[0]
}
} else if len(isrcMatches) == 1 {
selectedTrack = &isrcMatches[0]
} else {
selectedTrack = &result.Items[0]
}
} else {
selectedTrack = &result.Items[0]
}
if selectedTrack == nil {
return nil, fmt.Errorf("track not found")
}
fmt.Printf("Found: %s (%s)\n", selectedTrack.Title, selectedTrack.AudioQuality)
return selectedTrack, nil
fmt.Printf("Found: %s (%s)\n", trackInfo.Title, trackInfo.AudioQuality)
return &trackInfo, nil
}
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
fmt.Println("Fetching URL...")
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)
if err != nil {
fmt.Printf("✗ Tidal API request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode)
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
var apiResponses []TidalAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err)
return "", fmt.Errorf("failed to decode response: %w", err)
}
if len(apiResponses) == 0 {
fmt.Println("✗ Tidal API returned empty response")
return "", fmt.Errorf("no download URL in response")
}
for _, item := range apiResponses {
if item.OriginalTrackURL != "" {
fmt.Println("URL found")
fmt.Println("✓ Tidal download URL found")
return item.OriginalTrackURL, nil
}
}
fmt.Println("✗ No valid download URL in Tidal API response")
return "", fmt.Errorf("download URL not found in response")
}
func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
albumID = strings.ReplaceAll(albumID, "-", "/")
artURL := fmt.Sprintf("https://resources.tidal.com/images/%s/1280x1280.jpg", albumID)
// Decode base64 API URL
imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=")
artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID)
resp, err := t.client.Get(artURL)
if err != nil {
@@ -297,23 +347,37 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete")
return nil
}
func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) {
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err)
}
}
trackInfo, err := t.GetTrackInfo(query, isrc)
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
// Extract track ID from URL
trackID, err := t.GetTrackIDFromURL(tidalURL)
if err != nil {
return "", err
}
// Get track info by ID
trackInfo, err := t.GetTrackInfoByID(trackID)
if err != nil {
return "", err
}
@@ -322,35 +386,55 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
return "", fmt.Errorf("no track ID found")
}
var artists []string
if len(trackInfo.Artists) > 0 {
for _, artist := range trackInfo.Artists {
if artist.Name != "" {
artists = append(artists, artist.Name)
}
}
} else if trackInfo.Artist.Name != "" {
artists = append(artists, trackInfo.Artist.Name)
}
// Use Spotify metadata if provided, otherwise fallback to Tidal metadata
artistName := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
artistName := "Unknown Artist"
if len(artists) > 0 {
artistName = strings.Join(artists, ", ")
if artistName == "" {
var artists []string
if len(trackInfo.Artists) > 0 {
for _, artist := range trackInfo.Artists {
if artist.Name != "" {
artists = append(artists, artist.Name)
}
}
} else if trackInfo.Artist.Name != "" {
artists = append(artists, trackInfo.Artist.Name)
}
artistName = "Unknown Artist"
if len(artists) > 0 {
artistName = strings.Join(artists, ", ")
}
}
artistName = sanitizeFilename(artistName)
trackTitle := sanitizeFilename(trackInfo.Title)
if trackTitle == "" {
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
trackTitle = trackInfo.Title
if trackTitle == "" {
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
}
}
trackTitle = sanitizeFilename(trackTitle)
if albumTitle == "" {
albumTitle = trackInfo.Album.Title
}
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber)
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024))
return outputFilename, nil
return "EXISTS:" + outputFilename, nil
}
downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality)
@@ -386,12 +470,22 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
releaseYear = trackInfo.Album.ReleaseDate[:4]
}
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
trackNumberToEmbed = trackInfo.TrackNumber
} else {
trackNumberToEmbed = position
}
}
metadata := Metadata{
Title: trackInfo.Title,
Title: trackTitle,
Artist: artistName,
Album: trackInfo.Album.Title,
Album: albumTitle,
Date: releaseYear,
TrackNumber: trackInfo.TrackNumber,
TrackNumber: trackNumberToEmbed,
DiscNumber: trackInfo.VolumeNumber,
ISRC: trackInfo.ISRC,
}
@@ -403,22 +497,68 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
}
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal")
return outputFilename, nil
}
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) {
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
apis, err := t.GetAvailableAPIs()
if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err)
}
var lastError error
for i, apiURL := range apis {
fmt.Printf("[Tidal API %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
fallbackDownloader := NewTidalDownloader(apiURL)
result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
if err == nil {
fmt.Printf("✓ Success with: %s\n", apiURL)
return result, nil
}
lastError = err
errMsg := err.Error()
if len(errMsg) > 80 {
errMsg = errMsg[:80]
}
fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg)
}
return "", fmt.Errorf("all %d Tidal APIs failed. Last error: %v", len(apis), lastError)
}
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Tidal URL from Spotify track ID
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
apis, err := t.GetAvailableAPIs()
if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err)
}
// Get Tidal URL once
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
var lastError error
for i, apiURL := range apis {
fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
fallbackDownloader := NewTidalDownloader(apiURL)
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber)
result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
if err == nil {
fmt.Printf("✓ Success with: %s\n", apiURL)
return result, nil
@@ -435,7 +575,7 @@ func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality,
return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
}
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool) string {
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Build base filename based on format
@@ -449,8 +589,13 @@ func buildTidalFilename(title, artist string, trackNumber int, format string, in
}
// Add track number prefix if enabled
if includeTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
if includeTrackNumber && position > 0 {
// Use album track number if in album folder structure, otherwise use playlist position
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
return filename + ".flac"
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@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-progress": "^1.1.8",
+1 -1
View File
@@ -1 +1 @@
e00813ca84dd3deaade9854c0df093cd
72728e016fdcbb66d395ba3a681b8945
+69
View File
@@ -11,6 +11,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -641,6 +644,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
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-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
@@ -738,6 +754,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-menu@2.1.16':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
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:
@@ -2479,6 +2508,20 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.6
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
'@radix-ui/react-context@1.1.2(@types/react@19.2.6)(react@19.2.0)':
dependencies:
react: 19.2.0
@@ -2565,6 +2608,32 @@ snapshots:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
aria-hidden: 1.2.6
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-remove-scroll: 2.7.1(@types/react@19.2.6)(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+412 -1043
View File
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
name: string;
artists: string;
images: string;
release_date: string;
total_tracks: number;
artist_id?: string;
artist_url?: string;
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function AlbumInfo({
albumInfo,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onArtistClick,
onTrackClick,
}: AlbumInfoProps) {
return (
<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{albumInfo.images && (
<img
src={albumInfo.images}
alt={albumInfo.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
<span
className="font-medium cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
id: albumInfo.artist_id!,
name: albumInfo.artists,
external_urls: albumInfo.artist_url!,
})
}
>
{albumInfo.artists}
</span>
) : (
<span className="font-medium">{albumInfo.artists}</span>
)}
<span></span>
<span>{albumInfo.release_date}</span>
<span></span>
<span>{albumInfo.total_tracks} songs</span>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download All
</Button>
{selectedTracks.length > 0 && (
<Button
onClick={onDownloadSelected}
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length})
</Button>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={true}
folderName={albumInfo.name}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onPageChange={onPageChange}
onTrackClick={onTrackClick}
/>
</div>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api";
interface ArtistInfoProps {
artistInfo: {
name: string;
images: string;
followers: number;
genres: string[];
};
albumList: Array<{
id: string;
name: string;
images: string;
release_date: string;
album_type: string;
external_urls: string;
}>;
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
onPageChange: (page: number) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function ArtistInfo({
artistInfo,
albumList,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onAlbumClick,
onArtistClick,
onPageChange,
onTrackClick,
}: ArtistInfoProps) {
return (
<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{artistInfo.images && (
<img
src={artistInfo.images}
alt={artistInfo.name}
className="w-48 h-48 rounded-full shadow-lg object-cover"
/>
)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium">Artist</p>
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
<div className="flex items-center gap-2 text-sm flex-wrap">
<span>{artistInfo.followers.toLocaleString()} followers</span>
<span></span>
<span>{albumList.length} albums</span>
<span></span>
<span>{trackList.length} tracks</span>
{artistInfo.genres.length > 0 && (
<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{albumList.length > 0 && (
<div className="space-y-4">
<h3 className="text-2xl font-bold">Discography</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{albumList.map((album) => (
<div
key={album.id}
className="group cursor-pointer"
onClick={() =>
onAlbumClick({
id: album.id,
name: album.name,
external_urls: album.external_urls,
})
}
>
<div className="relative mb-4">
{album.images && (
<img
src={album.images}
alt={album.name}
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
/>
)}
</div>
<h4 className="font-semibold truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground">
{album.release_date?.split("-")[0]} {album.album_type}
</p>
</div>
))}
</div>
</div>
)}
{trackList.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">Popular Tracks</h3>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
size="sm"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download All
</Button>
{selectedTracks.length > 0 && (
<Button
onClick={onDownloadSelected}
size="sm"
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length})
</Button>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
folderName={artistInfo.name}
isArtistDiscography={true}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
</div>
)}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useState, useEffect, useRef } from "react";
import { Bug, Trash2, X, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
};
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export function DebugLogger() {
const [open, setOpen] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [copied, setCopied] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const unsubscribe = logger.subscribe(() => {
setLogs(logger.getLogs());
});
setLogs(logger.getLogs());
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (err) {
console.error("Failed to copy logs:", err);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-50 hover:opacity-100"
>
<Bug className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] p-6 [&>button]:hidden">
<DialogTitle className="text-sm font-medium">Debug Logs</DialogTitle>
<div className="absolute right-4 top-4 flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleCopy}
disabled={logs.length === 0}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleClear}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={() => setOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div
ref={scrollRef}
className="h-[400px] overflow-y-auto bg-muted/50 rounded-md p-3 font-mono text-xs"
>
{logs.length === 0 ? (
<p className="text-muted-foreground lowercase">no logs yet...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,29 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
currentTrack: { name: string; artists: string } | null;
onStop: () => void;
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
return (
<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={progress} className="h-2 flex-1" />
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" />
Stop
</Button>
</div>
<p className="text-xs text-muted-foreground">
{progress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>
</div>
);
}
@@ -0,0 +1,30 @@
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { Download } from "lucide-react";
export function DownloadProgressToast() {
const progress = useDownloadProgress();
if (!progress.is_downloading) {
return null;
}
return (
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<div className="bg-background border rounded-lg shadow-lg p-3">
<div className="flex items-center gap-3">
<Download className="h-4 w-4 text-primary animate-bounce" />
<div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB
</p>
{progress.speed_mbps > 0 && (
<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps.toFixed(2)} MB/s
</p>
)}
</div>
</div>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { X } from "lucide-react";
export interface HistoryItem {
id: string;
url: string;
type: "track" | "album" | "playlist" | "artist";
name: string;
artist: string;
image: string;
timestamp: number;
}
interface FetchHistoryProps {
history: HistoryItem[];
onSelect: (item: HistoryItem) => void;
onRemove: (id: string) => void;
}
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
if (history.length === 0) return null;
const getTypeLabel = (type: string) => {
switch (type) {
case "track":
return "Track";
case "album":
return "Album";
case "playlist":
return "Playlist";
case "artist":
return "Artist";
default:
return type;
}
};
return (
<div className="space-y-2">
<span className="text-sm text-muted-foreground">Recent Fetches</span>
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
{history.map((item) => (
<div
key={item.id}
className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible"
onClick={() => onSelect(item)}
>
<button
type="button"
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
onClick={(e) => {
e.stopPropagation();
onRemove(item.id);
}}
>
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
</button>
<div className="p-2">
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
{item.image ? (
<img
src={item.image}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
No Image
</div>
)}
</div>
<div className="space-y-0.5">
<p className="text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
<p
className="text-xs text-muted-foreground truncate"
title={item.artist}
>
{item.artist}
</p>
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{getTypeLabel(item.type)}
</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Settings } from "@/components/Settings";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface HeaderProps {
version: string;
hasUpdate: boolean;
}
export function Header({ version, hasUpdate }: HeaderProps) {
return (
<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
<img
src="/icon.svg"
alt="SpotiFLAC"
className="w-12 h-12 cursor-pointer"
onClick={() => window.location.reload()}
/>
<h1
className="text-4xl font-bold cursor-pointer"
onClick={() => window.location.reload()}
>
SpotiFLAC
</h1>
<div className="relative">
<Badge variant="default" asChild>
<a
href="https://github.com/afkarxyz/SpotiFLAC/releases"
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer hover:opacity-80 transition-opacity"
>
v{version}
</a>
</Badge>
{hasUpdate && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
)}
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music no account required.
</p>
</div>
<div className="absolute right-0 top-0 flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild>
<a
href="https://github.com/afkarxyz/SpotiFLAC/issues"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub Issues"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Report bug or request feature</p>
</TooltipContent>
</Tooltip>
<Settings />
</div>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api";
interface PlaylistInfoProps {
playlistInfo: {
owner: {
name: string;
display_name: string;
images: string;
};
tracks: {
total: number;
};
followers: {
total: number;
};
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
onTrackClick: (track: TrackMetadata) => void;
}
export function PlaylistInfo({
playlistInfo,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onAlbumClick,
onArtistClick,
onTrackClick,
}: PlaylistInfoProps) {
return (
<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{playlistInfo.owner.images && (
<img
src={playlistInfo.owner.images}
alt={playlistInfo.owner.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Playlist</p>
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{playlistInfo.owner.display_name}</span>
<span></span>
<span>{playlistInfo.tracks.total} songs</span>
<span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download All
</Button>
{selectedTracks.length > 0 && (
<Button
onClick={onDownloadSelected}
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length})
</Button>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
folderName={playlistInfo.owner.name}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, ArrowUpDown, XCircle } from "lucide-react";
interface SearchAndSortProps {
searchQuery: string;
sortBy: string;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
}
export function SearchAndSort({
searchQuery,
sortBy,
onSearchChange,
onSortChange,
}: SearchAndSortProps) {
return (
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 pr-8"
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onSearchChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-[200px] gap-1.5">
<ArrowUpDown className="h-4 w-4" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent>
</Select>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Search, Info, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory";
interface SearchBarProps {
url: string;
loading: boolean;
onUrlChange: (url: string) => void;
onFetch: () => void;
history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void;
hasResult: boolean;
}
export function SearchBar({
url,
loading,
onUrlChange,
onFetch,
history,
onHistorySelect,
onHistoryRemove,
hasResult,
}: SearchBarProps) {
return (
<div className="space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<InputWithContext
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<Search className="h-4 w-4" />
Fetch
</>
)}
</Button>
</div>
</div>
{!hasResult && (
<FetchHistory
history={history}
onSelect={onHistorySelect}
onRemove={onHistoryRemove}
/>
)}
</div>
);
}
+209 -103
View File
@@ -1,12 +1,11 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
@@ -19,16 +18,46 @@ import {
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X } from "lucide-react";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { OpenFolder } from "../../wailsjs/go/main/App";
import { SelectFolder } from "../../wailsjs/go/main/App";
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
const DeezerIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="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>
);
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="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>
);
export function Settings() {
const [open, setOpen] = useState(false);
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [, setIsLoadingDefaults] = useState(false);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
// Apply saved settings
useEffect(() => {
@@ -56,6 +85,11 @@ export function Settings() {
if (open) {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
// Update isDark state after theme is applied
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
// Setup listener for system theme changes during preview
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
@@ -63,6 +97,9 @@ export function Settings() {
if (tempSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(tempSettings.theme);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}
};
@@ -134,7 +171,7 @@ export function Settings() {
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
};
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal") => {
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => {
setTempSettings((prev) => ({ ...prev, downloader: value }));
};
@@ -147,17 +184,19 @@ export function Settings() {
};
const handleBrowseFolder = async () => {
if (!tempSettings.downloadPath) {
alert("Please enter a download path first");
return;
}
try {
// Call backend to open folder in file explorer
await OpenFolder(tempSettings.downloadPath);
// Call backend to open folder selection dialog
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
console.log("Selected path:", selectedPath);
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
} else {
console.log("No folder selected or user cancelled");
}
} catch (error) {
console.error("Error opening folder:", error);
alert(`Error opening folder: ${error}`);
console.error("Error selecting folder:", error);
alert(`Error selecting folder: ${error}`);
}
};
@@ -168,75 +207,150 @@ export function Settings() {
<SettingsIcon className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[85vh] flex flex-col" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 overflow-y-auto flex-1">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<Input
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => handleDownloadPathChange(e.target.value)}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder}>
<FolderOpen className="h-4 w-4" />
Open
</Button>
<DialogContent className="sm:max-w-[600px] flex flex-col p-6 [&>button]:hidden" aria-describedby={undefined}>
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleCancel}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Settings</DialogTitle>
<div className="grid grid-cols-[1.2fr_0.8fr] gap-6 py-2">
{/* Left Column */}
<div className="space-y-4">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => handleDownloadPathChange(e.target.value)}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
</div>
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<Select
value={tempSettings.downloader}
onValueChange={handleDownloaderChange}
>
<SelectTrigger id="downloader">
<SelectValue placeholder="Select a source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="deezer">
<span className="flex items-center">
<DeezerIcon />
Deezer
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Mode Selection */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Theme</Label>
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Color Selection */}
<div className="space-y-2">
<Label htmlFor="theme">Theme Color</Label>
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full border border-border"
style={{
backgroundColor: isDark
? theme.cssVars.dark.primary
: theme.cssVars.light.primary
}}
/>
{theme.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<Select
value={tempSettings.downloader}
onValueChange={handleDownloaderChange}
>
<SelectTrigger id="downloader">
<SelectValue placeholder="Select a source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (Tidal Deezer)</SelectItem>
<SelectItem value="tidal">Tidal</SelectItem>
<SelectItem value="deezer">Deezer</SelectItem>
</SelectContent>
</Select>
</div>
{/* File Settings */}
<div className="space-y-3 pt-3 border-t">
<h3 className="font-medium text-sm">File Settings</h3>
{/* Right Column */}
<div className="space-y-4">
{/* Filename Format */}
<div className="space-y-1.5">
<div className="space-y-2">
<Label className="text-sm">Filename Format</Label>
<RadioGroup
value={tempSettings.filenameFormat}
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
className="flex flex-wrap gap-3"
>
<div className="flex items-center space-x-1.5">
<div className="flex items-center space-x-2">
<RadioGroupItem value="title-artist" id="title-artist" />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-xs">Title - Artist</Label>
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
</div>
<div className="flex items-center space-x-1.5">
<div className="flex items-center space-x-2">
<RadioGroupItem value="artist-title" id="artist-title" />
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-xs">Artist - Title</Label>
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
</div>
<div className="flex items-center space-x-1.5">
<div className="flex items-center space-x-2">
<RadioGroupItem value="title" id="title" />
<Label htmlFor="title" className="cursor-pointer font-normal text-xs">Title</Label>
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
</div>
</RadioGroup>
</div>
{/* Subfolder Options */}
<div className="border-t" />
{/* Folder Settings */}
<div className="space-y-2">
<h3 className="font-medium text-sm">Folder Settings</h3>
<div className="flex items-center gap-2">
<Checkbox
id="track-number"
@@ -244,6 +358,14 @@ export function Settings() {
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Adds track numbers to filenames. Uses album track numbers when Album Subfolder is enabled, otherwise uses playlist position</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
@@ -251,7 +373,15 @@ export function Settings() {
checked={tempSettings.artistSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
/>
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder (Playlist only)</Label>
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist only</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
@@ -259,54 +389,30 @@ export function Settings() {
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder (Playlist & Discography)</Label>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist & Discography</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{/* Theme Mode Selection */}
<div className="space-y-1.5 pt-3 border-t">
<Label htmlFor="theme-mode" className="text-sm">Theme</Label>
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Color Selection */}
<div className="space-y-1.5">
<Label htmlFor="theme" className="text-sm">Theme Color</Label>
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.name} value={theme.name}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="outline" onClick={handleReset} size="sm" className="gap-1.5">
<RotateCcw className="h-3.5 w-3.5" />
<Button variant="outline" onClick={handleReset} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel} size="sm">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} size="sm" className="gap-1.5">
<Save className="h-3.5 w-3.5" />
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
+70
View File
@@ -0,0 +1,70 @@
import { useState } from "react";
import { X, Minus, Square } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() {
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
return (
<>
{/* Draggable area */}
<div
className="fixed top-0 left-0 right-0 h-12 z-40"
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
onDoubleClick={handleMaximize}
/>
{/* Window control buttons */}
<div className="fixed top-4 left-4 z-50 flex gap-2">
<button
onClick={handleClose}
onMouseEnter={() => setHoveredButton("close")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
{hoveredButton === "close" && (
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
)}
</button>
<button
onClick={handleMinimize}
onMouseEnter={() => setHoveredButton("minimize")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Minimize"
>
{hoveredButton === "minimize" && (
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
)}
</button>
<button
onClick={handleMaximize}
onMouseEnter={() => setHoveredButton("maximize")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Maximize"
>
{hoveredButton === "maximize" && (
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
)}
</button>
</div>
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import type { TrackMetadata } from "@/types/api";
interface TrackInfoProps {
track: TrackMetadata & { album_name: string; release_date: string };
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
isFailed: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onOpenFolder: () => void;
}
export function TrackInfo({
track,
isDownloading,
downloadingTrack,
isDownloaded,
isFailed,
onDownload,
onOpenFolder,
}: TrackInfoProps) {
return (
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{track.images && (
<img
src={track.images}
alt={track.name}
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0"
/>
)}
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isDownloaded && (
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
)}
{isFailed && (
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
)}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{track.album_name}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
</div>
</div>
{track.isrc && (
<div className="flex gap-2">
<Button
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4" />
Download
</>
)}
</Button>
{isDownloaded && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}
+341
View File
@@ -0,0 +1,341 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, SkipForward } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import type { TrackMetadata } from "@/types/api";
interface TrackListProps {
tracks: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
currentPage: number;
itemsPerPage: number;
showCheckboxes?: boolean;
hideAlbumColumn?: boolean;
folderName?: string;
isArtistDiscography?: boolean;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function TrackList({
tracks,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
currentPage,
itemsPerPage,
showCheckboxes = false,
hideAlbumColumn = false,
folderName,
isArtistDiscography = false,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onPageChange,
onAlbumClick,
onArtistClick,
onTrackClick,
}: TrackListProps) {
let filteredTracks = tracks.filter((track) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
track.name.toLowerCase().includes(query) ||
track.artists.toLowerCase().includes(query) ||
track.album_name.toLowerCase().includes(query)
);
});
// Apply sorting
if (sortBy === "title-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === "title-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
} else if (sortBy === "artist-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
} else if (sortBy === "artist-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
} else if (sortBy === "duration-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
} else if (sortBy === "duration-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
} else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
});
} else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
const allSelected =
tracksWithIsrc.length > 0 &&
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="space-y-4">
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
{showCheckboxes && (
<th className="h-12 px-4 text-left align-middle w-12">
<Checkbox
checked={allSelected}
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
/>
</th>
)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
#
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title
</th>
{!hideAlbumColumn && (
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album
</th>
)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
Duration
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
Actions
</th>
</tr>
</thead>
<tbody>
{paginatedTracks.map((track, index) => (
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (
<td className="p-4 align-middle">
{track.isrc && (
<Checkbox
checked={selectedTracks.includes(track.isrc)}
onCheckedChange={() => onToggleTrack(track.isrc)}
/>
)}
</td>
)}
<td className="p-4 align-middle text-sm text-muted-foreground">
{startIndex + index + 1}
</td>
<td className="p-4 align-middle">
<div className="flex items-center gap-3">
{track.images && (
<img
src={track.images}
alt={track.name}
className="w-10 h-10 rounded object-cover"
/>
)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (
<span
className="font-medium cursor-pointer hover:underline"
onClick={() => onTrackClick(track)}
>
{track.name}
</span>
) : (
<span className="font-medium">{track.name}</span>
)}
{skippedTracks.has(track.isrc) ? (
<SkipForward className="h-4 w-4 text-yellow-500 shrink-0" />
) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
) : failedTracks.has(track.isrc) ? (
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
) : null}
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? (
track.artists_data.map((artist, i, arr) => (
<span key={artist.id}>
{onArtistClick ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})
}
>
{artist.name}
</span>
) : (
artist.name
)}
{i < arr.length - 1 && ", "}
</span>
))
) : onArtistClick && track.artist_id && track.artist_url ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
id: track.artist_id!,
name: track.artists,
external_urls: track.artist_url!,
})
}
>
{track.artists}
</span>
) : (
track.artists
)}
</span>
</div>
</div>
</td>
{!hideAlbumColumn && (
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
{onAlbumClick && track.album_id && track.album_url ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onAlbumClick({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})
}
>
{track.album_name}
</span>
) : (
track.album_name
)}
</td>
)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)}
</td>
<td className="p-4 align-middle text-center">
{track.isrc && (
<Button
onClick={() =>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
}
size="sm"
className="gap-1.5"
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4" />
Download
</>
)}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) onPageChange(currentPage - 1);
}}
className={
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
onPageChange(page);
}}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages) onPageChange(currentPage + 1);
}}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
+252
View File
@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground 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 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
@@ -0,0 +1,214 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
export interface InputWithContextProps
extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void;
}
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(
({ className, type, onValueChange, onChange, ...props }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [hasSelection, setHasSelection] = React.useState(false);
const [canPaste, setCanPaste] = React.useState(false);
// Combine refs
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
// Check selection state
const updateSelectionState = () => {
const input = inputRef.current;
if (!input) return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
setHasSelection(start !== end);
};
// Check clipboard permission
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setCanPaste(text.length > 0);
} catch {
setCanPaste(false);
}
};
React.useEffect(() => {
checkClipboard();
}, []);
const handleCut = async () => {
const input = inputRef.current;
if (!input) return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
const newValue = input.value.substring(0, start) + input.value.substring(end);
// Update value and trigger change
input.value = newValue;
input.setSelectionRange(start, start);
// Trigger React onChange
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
} catch (err) {
console.error("Failed to cut:", err);
}
}
};
const handleCopy = async () => {
const input = inputRef.current;
if (!input) return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
input.focus();
} catch (err) {
console.error("Failed to copy:", err);
}
}
};
const handlePaste = async () => {
const input = inputRef.current;
if (!input) return;
try {
const text = await navigator.clipboard.readText();
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue =
input.value.substring(0, start) + text + input.value.substring(end);
// Update value and trigger change
input.value = newValue;
const newPosition = start + text.length;
input.setSelectionRange(newPosition, newPosition);
// Trigger React onChange
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
await checkClipboard();
} catch (err) {
console.error("Failed to paste:", err);
}
};
const handleSelectAll = () => {
const input = inputRef.current;
if (!input) return;
input.select();
input.focus();
updateSelectionState();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
};
return (
<ContextMenu onOpenChange={checkClipboard}>
<ContextMenuTrigger asChild>
<Input
ref={inputRef}
type={type}
className={className}
onChange={handleInputChange}
onSelect={updateSelectionState}
onMouseUp={updateSelectionState}
onKeyUp={updateSelectionState}
{...props}
/>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
onSelect={handleCut}
disabled={!hasSelection || props.disabled || props.readOnly}
>
<Scissors className="mr-2 h-4 w-4" />
Cut
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handleCopy}
disabled={!hasSelection || props.disabled}
>
<Copy className="mr-2 h-4 w-4" />
Copy
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handlePaste}
disabled={!canPaste || props.disabled || props.readOnly}
>
<Clipboard className="mr-2 h-4 w-4" />
Paste
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={handleSelectAll}
disabled={!inputRef.current?.value || props.disabled}
>
<Type className="mr-2 h-4 w-4" />
Select All
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
);
InputWithContext.displayName = "InputWithContext";
export { InputWithContext };
+482
View File
@@ -0,0 +1,482 @@
import { useState, useRef } from "react";
import { downloadTrack } from "@/lib/api";
import { getSettings } from "@/lib/settings";
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";
export function useDownload() {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
const [failedTracks, setFailedTracks] = useState<Set<string>>(new Set());
const [skippedTracks, setSkippedTracks] = useState<Set<string>>(new Set());
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
name: string;
artists: string;
} | null>(null);
const shouldStopDownloadRef = useRef(false);
const downloadWithAutoFallback = async (
isrc: string,
settings: any,
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number,
spotifyId?: string
) => {
let service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true; // Use album track number for discography with album subfolder
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true; // Use album track number when both artist and album subfolders are used
}
}
}
if (service === "auto") {
// Get all streaming URLs once from song.link API
let streamingURLs: any = null;
if (spotifyId) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId);
streamingURLs = JSON.parse(urlsJson);
} catch (err) {
console.error("Failed to get streaming URLs:", err);
}
}
// Try Tidal first
if (streamingURLs?.tidal_url) {
try {
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
const tidalResponse = await downloadTrack({
isrc,
service: "tidal",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.tidal_url,
});
if (tidalResponse.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
return tidalResponse;
}
logger.warning(`tidal failed, trying deezer...`);
} catch (tidalErr) {
logger.error(`tidal error: ${tidalErr}`);
}
}
// Try Deezer second
if (streamingURLs?.deezer_url) {
try {
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
const deezerResponse = await downloadTrack({
isrc,
service: "deezer",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
});
if (deezerResponse.success) {
logger.success(`deezer: ${trackName} - ${artistName}`);
return deezerResponse;
}
logger.warning(`deezer failed, trying amazon...`);
} catch (deezerErr) {
logger.error(`deezer error: ${deezerErr}`);
}
}
// Try Amazon third
if (streamingURLs?.amazon_url) {
try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const amazonResponse = await downloadTrack({
isrc,
service: "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.amazon_url,
});
if (amazonResponse.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return amazonResponse;
}
logger.warning(`amazon failed, trying qobuz...`);
} catch (amazonErr) {
logger.error(`amazon error: ${amazonErr}`);
}
}
// Try Qobuz as last fallback
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
service = "qobuz";
}
return await downloadTrack({
isrc,
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
});
};
const handleDownloadTrack = async (
isrc: string,
trackName?: string,
artistName?: string,
albumName?: string,
spotifyId?: string,
playlistName?: string,
isArtistDiscography?: boolean
) => {
if (!isrc) {
toast.error("No ISRC found for this track");
return;
}
logger.info(`starting download: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingTrack(isrc);
try {
// Single track download - use playlistName if provided for folder structure
const response = await downloadWithAutoFallback(
isrc,
settings,
trackName,
artistName,
albumName,
playlistName,
isArtistDiscography,
undefined, // Don't pass position for single track
spotifyId
);
if (response.success) {
if (response.already_exists) {
toast.info(response.message);
setSkippedTracks((prev) => new Set(prev).add(isrc));
} else {
toast.success(response.message);
}
setDownloadedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(isrc);
return newSet;
});
} else {
toast.error(response.error || "Download failed");
setFailedTracks((prev) => new Set(prev).add(isrc));
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Download failed");
setFailedTracks((prev) => new Set(prev).add(isrc));
} finally {
setDownloadingTrack(null);
}
};
const handleDownloadSelected = async (
selectedTracks: string[],
allTracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
) => {
if (selectedTracks.length === 0) {
toast.error("No tracks selected");
return;
}
logger.info(`starting batch download: ${selectedTracks.length} selected tracks`);
const settings = getSettings();
setIsDownloading(true);
setBulkDownloadType("selected");
setDownloadProgress(0);
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
const total = selectedTracks.length;
for (let i = 0; i < selectedTracks.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
);
break;
}
const isrc = selectedTracks[i];
const track = allTracks.find((t) => t.isrc === isrc);
setDownloadingTrack(isrc);
if (track) {
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
}
try {
// Use sequential numbering (1, 2, 3...) for selected tracks
const response = await downloadWithAutoFallback(
isrc,
settings,
track?.name,
track?.artists,
track?.album_name,
playlistName,
isArtistDiscography,
i + 1, // Sequential position based on selection order
track?.spotify_id
);
if (response.success) {
if (response.already_exists) {
skippedCount++;
logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(isrc));
} else {
successCount++;
logger.success(`downloaded: ${track?.name} - ${track?.artists}`);
}
setDownloadedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(isrc); // Remove from failed if it was there
return newSet;
});
} else {
errorCount++;
logger.error(`failed: ${track?.name} - ${track?.artists}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
}
} catch (err) {
errorCount++;
logger.error(`error: ${track?.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
// Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`);
} else if (errorCount === 0 && successCount === 0) {
// All skipped
toast.info(`${skippedCount} tracks already exist`);
} else if (errorCount === 0) {
// Mix of downloaded and skipped
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
} else {
// Has errors
const parts = [];
if (successCount > 0) parts.push(`${successCount} downloaded`);
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
parts.push(`${errorCount} failed`);
toast.warning(parts.join(", "));
}
};
const handleDownloadAll = async (
tracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc);
if (tracksWithIsrc.length === 0) {
toast.error("No tracks available for download");
return;
}
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
const settings = getSettings();
setIsDownloading(true);
setBulkDownloadType("all");
setDownloadProgress(0);
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
const total = tracksWithIsrc.length;
for (let i = 0; i < tracksWithIsrc.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
);
break;
}
const track = tracksWithIsrc[i];
setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try {
const response = await downloadWithAutoFallback(
track.isrc,
settings,
track.name,
track.artists,
track.album_name,
playlistName,
isArtistDiscography,
i + 1,
track.spotify_id
);
if (response.success) {
if (response.already_exists) {
skippedCount++;
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
} else {
successCount++;
logger.success(`downloaded: ${track.name} - ${track.artists}`);
}
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(track.isrc); // Remove from failed if it was there
return newSet;
});
} else {
errorCount++;
logger.error(`failed: ${track.name} - ${track.artists}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc));
}
} catch (err) {
errorCount++;
logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
// Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) {
toast.success(`Downloaded ${successCount} tracks successfully`);
} else if (errorCount === 0 && successCount === 0) {
// All skipped
toast.info(`${skippedCount} tracks already exist`);
} else if (errorCount === 0) {
// Mix of downloaded and skipped
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
} else {
// Has errors
const parts = [];
if (successCount > 0) parts.push(`${successCount} downloaded`);
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
parts.push(`${errorCount} failed`);
toast.warning(parts.join(", "));
}
};
const handleStopDownload = () => {
logger.info("download stopped by user");
shouldStopDownloadRef.current = true;
toast.info("Stopping download...");
};
const resetDownloadedTracks = () => {
setDownloadedTracks(new Set());
setFailedTracks(new Set());
setSkippedTracks(new Set());
};
return {
downloadProgress,
isDownloading,
downloadingTrack,
bulkDownloadType,
downloadedTracks,
failedTracks,
skippedTracks,
currentDownloadInfo,
handleDownloadTrack,
handleDownloadSelected,
handleDownloadAll,
handleStopDownload,
resetDownloadedTracks,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, useEffect, useRef } from "react";
import { GetDownloadProgress } from "../../wailsjs/go/main/App";
export interface DownloadProgressInfo {
is_downloading: boolean;
mb_downloaded: number;
speed_mbps: number;
}
export function useDownloadProgress() {
const [progress, setProgress] = useState<DownloadProgressInfo>({
is_downloading: false,
mb_downloaded: 0,
speed_mbps: 0,
});
const intervalRef = useRef<number | null>(null);
useEffect(() => {
// Poll progress every 200ms for smooth updates
const pollProgress = async () => {
try {
const progressInfo = await GetDownloadProgress();
setProgress(progressInfo);
} catch (error) {
console.error("Failed to get download progress:", error);
}
};
// Start polling
intervalRef.current = window.setInterval(pollProgress, 200);
// Initial fetch
pollProgress();
// Cleanup
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return progress;
}
+204
View File
@@ -0,0 +1,204 @@
import { useState } from "react";
import { fetchSpotifyMetadata } from "@/lib/api";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { logger } from "@/lib/logger";
import type { SpotifyMetadataResponse } from "@/types/api";
export function useMetadata() {
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
const [timeoutValue, setTimeoutValue] = useState(60);
const [pendingUrl, setPendingUrl] = useState("");
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{
id: string;
name: string;
external_urls: string;
} | null>(null);
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
const getUrlType = (url: string): string => {
if (url.includes("/track/")) return "track";
if (url.includes("/album/")) return "album";
if (url.includes("/playlist/")) return "playlist";
if (url.includes("/artist/")) return "artist";
return "unknown";
};
const fetchMetadataDirectly = async (url: string) => {
const urlType = getUrlType(url);
logger.info(`fetching ${urlType} metadata...`);
logger.debug(`url: ${url}`);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(url);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
setMetadata(data);
// Log detailed info based on type
if ("track" in data) {
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
} else if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
} else if ("playlist_info" in data) {
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
} else if ("artist_info" in data) {
logger.success(`fetched artist: ${data.artist_info.name}`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
const handleFetchMetadata = async (url: string) => {
if (!url.trim()) {
logger.warning("empty url provided");
toast.error("Please enter a Spotify URL");
return;
}
let urlToFetch = url.trim();
const isArtistUrl = urlToFetch.includes("/artist/");
if (isArtistUrl && !urlToFetch.includes("/discography")) {
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
logger.debug("converted to discography url");
}
if (isArtistUrl) {
logger.info("artist url detected, showing timeout dialog");
setPendingUrl(urlToFetch);
setPendingArtistName(null); // Clear artist name for URL input
setShowTimeoutDialog(true);
} else {
await fetchMetadataDirectly(urlToFetch);
}
return urlToFetch;
};
const handleConfirmFetch = async () => {
setShowTimeoutDialog(false);
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
logger.debug(`url: ${pendingUrl}`);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
setMetadata(data);
if ("artist_info" in data) {
logger.success(`fetched artist: ${data.artist_info.name}`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
const handleAlbumClick = (album: {
id: string;
name: string;
external_urls: string;
}) => {
logger.debug(`album clicked: ${album.name}`);
setSelectedAlbum(album);
setShowAlbumDialog(true);
};
const handleArtistClick = async (artist: {
id: string;
name: string;
external_urls: string;
}) => {
logger.debug(`artist clicked: ${artist.name}`);
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setPendingUrl(artistUrl);
setPendingArtistName(artist.name);
setShowTimeoutDialog(true);
return artistUrl;
};
const handleConfirmAlbumFetch = async () => {
if (!selectedAlbum) return;
const albumUrl = selectedAlbum.external_urls;
logger.info(`fetching album: ${selectedAlbum.name}...`);
logger.debug(`url: ${albumUrl}`);
setShowAlbumDialog(false);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(albumUrl);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
setMetadata(data);
if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Album metadata fetched successfully");
return albumUrl;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
setLoading(false);
setSelectedAlbum(null);
}
};
return {
loading,
metadata,
showTimeoutDialog,
setShowTimeoutDialog,
timeoutValue,
setTimeoutValue,
showAlbumDialog,
setShowAlbumDialog,
selectedAlbum,
pendingArtistName,
handleFetchMetadata,
handleConfirmFetch,
handleAlbumClick,
handleConfirmAlbumFetch,
handleArtistClick,
};
}
+5 -1
View File
@@ -119,10 +119,14 @@
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
code, pre, .font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
}
}
@theme inline {
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
/* Sonner Toast Styling */
[data-sonner-toast] {
@apply rounded-lg shadow-lg border;
+66
View File
@@ -0,0 +1,66 @@
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
export interface LogEntry {
timestamp: Date;
level: LogLevel;
message: string;
}
class Logger {
private logs: LogEntry[] = [];
private maxLogs = 500;
private listeners: Set<() => void> = new Set();
private addLog(level: LogLevel, message: string) {
const entry: LogEntry = {
timestamp: new Date(),
level,
message: message.toLowerCase(),
};
this.logs.push(entry);
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
this.notifyListeners();
}
info(message: string) {
this.addLog("info", message);
}
success(message: string) {
this.addLog("success", message);
}
warning(message: string) {
this.addLog("warning", message);
}
error(message: string) {
this.addLog("error", message);
}
debug(message: string) {
this.addLog("debug", message);
}
getLogs(): LogEntry[] {
return [...this.logs];
}
clear() {
this.logs = [];
this.notifyListeners();
}
subscribe(listener: () => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners() {
this.listeners.forEach((listener) => listener());
}
}
export const logger = new Logger();
+16 -3
View File
@@ -2,13 +2,23 @@ import { GetDefaults } from "../../wailsjs/go/main/App";
export interface Settings {
downloadPath: string;
downloader: "auto" | "deezer" | "tidal";
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
theme: string;
themeMode: "auto" | "light" | "dark";
filenameFormat: "title-artist" | "artist-title" | "title";
artistSubfolder: boolean;
albumSubfolder: boolean;
trackNumber: boolean;
operatingSystem: "Windows" | "linux/MacOS"
}
// Auto-detect operating system
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
if (platform.includes('win')) {
return "Windows";
}
return "linux/MacOS";
}
export const DEFAULT_SETTINGS: Settings = {
@@ -20,16 +30,17 @@ export const DEFAULT_SETTINGS: Settings = {
artistSubfolder: false,
albumSubfolder: false,
trackNumber: false,
operatingSystem: detectOS()
};
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
return data.downloadPath || "C:\\Users\\Public\\Music";
return data.downloadPath || "";
} catch (error) {
console.error("Failed to fetch default path:", error);
return "";
}
return "C:\\Users\\Public\\Music";
}
const SETTINGS_KEY = "spotiflac-settings";
@@ -44,6 +55,8 @@ export function getSettings(): Settings {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
// Always use detected OS (don't persist it)
parsed.operatingSystem = detectOS();
return { ...DEFAULT_SETTINGS, ...parsed };
}
} catch (error) {
+31 -11
View File
@@ -1,31 +1,51 @@
import { toast } from 'sonner';
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound } from './audio';
import { toast } from "sonner";
import {
playSuccessSound,
playErrorSound,
playWarningSound,
playInfoSound,
} from "./audio";
import { logger } from "./logger";
const toastStyle = {
className: "font-mono lowercase",
};
// Wrapper functions for toast with sound effects
export const toastWithSound = {
success: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.success(msg);
playSuccessSound();
return toast.success(message, data);
return toast.success(msg, { ...toastStyle, ...data });
},
error: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.error(msg);
playErrorSound();
return toast.error(message, data);
return toast.error(msg, { ...toastStyle, ...data });
},
warning: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.warning(msg);
playWarningSound();
return toast.warning(message, data);
return toast.warning(msg, { ...toastStyle, ...data });
},
info: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.info(msg);
playInfoSound();
return toast.info(message, data);
return toast.info(msg, { ...toastStyle, ...data });
},
// Default toast without specific type
message: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.info(msg);
playInfoSound();
return toast(message, data);
return toast(msg, { ...toastStyle, ...data });
},
};
+41
View File
@@ -1,6 +1,47 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { Settings } from "./settings";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function sanitizePath(input: string, os: string): string {
if (os === "Windows") {
return input.replace(/[<>:"/\\|?*]/g, "_");
}
// unix-based OS
return input.replace(/\//g, "_");
}
export function joinPath(os: string, ...parts: string[]): string {
const sep = os === "Windows" ? "\\" : "/";
const filtered = parts.filter(Boolean);
if (filtered.length === 0) return "";
const joined = filtered
.map((p, i) => {
// For first part, only remove trailing slashes (preserve leading slash for absolute paths)
if (i === 0) {
return p.replace(/[/\\]+$/g, "");
}
// For other parts, remove both leading and trailing slashes
return p.replace(/^[/\\]+|[/\\]+$/g, "");
})
.filter(Boolean) // Remove empty strings after trimming
.join(sep);
return joined;
}
export function buildOutputPath(settings: Settings, folder?: string) {
const os = settings.operatingSystem;
const base = settings.downloadPath || "";
const sanitized = folder ? sanitizePath(folder, os) : undefined;
return sanitized ? joinPath(os, base, sanitized) : base;
}
+13 -9
View File
@@ -1,12 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { Toaster } from '@/components/ui/sonner'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { Toaster } from "@/components/ui/sonner";
import { DebugLogger } from "@/components/DebugLogger";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Toaster />
</StrictMode>,
)
<Toaster position="bottom-left" duration={1000} />
<div className="fixed bottom-2 left-2 z-50">
<DebugLogger />
</div>
</StrictMode>
);
+21 -1
View File
@@ -1,3 +1,9 @@
export interface ArtistSimple {
id: string;
name: string;
external_urls: string;
}
export interface TrackMetadata {
artists: string;
name: string;
@@ -9,6 +15,12 @@ export interface TrackMetadata {
external_urls: string;
isrc: string;
album_type?: string;
spotify_id?: string;
album_id?: string;
album_url?: string;
artist_id?: string;
artist_url?: string;
artists_data?: ArtistSimple[];
}
export interface TrackResponse {
@@ -97,14 +109,21 @@ export type SpotifyMetadataResponse =
export interface DownloadRequest {
isrc: string;
service: "deezer" | "tidal";
service: "deezer" | "tidal" | "qobuz" | "amazon";
query?: string;
track_name?: string;
artist_name?: string;
album_name?: string;
api_url?: string;
output_dir?: string;
audio_format?: string;
folder_name?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
use_album_track_number?: boolean;
spotify_id?: string;
service_url?: string;
}
export interface DownloadResponse {
@@ -112,6 +131,7 @@ export interface DownloadResponse {
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
export interface HealthResponse {
+10 -10
View File
@@ -12,29 +12,29 @@ require (
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/labstack/echo/v4 v4.13.4 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
)
+20 -22
View File
@@ -10,16 +10,16 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
@@ -35,9 +35,8 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -49,8 +48,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@@ -59,29 +58,28 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+10 -6
View File
@@ -19,9 +19,12 @@ func main() {
// Create application with options
err := wails.Run(&options.App{
Title: "SpotiFLAC",
Width: 1024,
Height: 600,
Title: "SpotiFLAC",
Width: 1024,
Height: 600,
MinWidth: 1024,
MinHeight: 600,
Frameless: true,
AssetServer: &assetserver.Options{
Assets: assets,
},
@@ -31,9 +34,10 @@ func main() {
app,
},
Windows: &windows.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,
DisableWindowIcon: false,
WebviewIsTransparent: false,
WindowIsTranslucent: false,
DisableWindowIcon: false,
DisableFramelessWindowDecorations: false,
},
})
+1 -1
View File
@@ -7,4 +7,4 @@
"katze.qqdl.site",
"wolf.qqdl.site",
"tidal.kinoplus.online"
]
]
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "5.5"
"version": "6.1"
}
+3 -7
View File
@@ -7,17 +7,13 @@
"frontend:dev:watcher": "pnpm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "afkarxyz",
"email": "hi@afkarxyz.fun"
"name": "afkarxyz"
},
"info": {
"companyName": "afkarxyz",
"productName": "SpotiFLAC",
"productVersion": "5.6",
"copyright": "Copyright © 2025",
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
"productVersion": "6.2"
},
"wailsjsdir": "./frontend",
"assetdir": "./frontend/dist",
"reloaddirs": "./frontend/src"
}
}