Compare commits

...

55 Commits

Author SHA1 Message Date
afkarxyz b85ed89af3 v6.8 2025-12-14 15:35:11 +07:00
afkarxyz 8e78a882a3 v6.8 2025-12-14 12:36:37 +07:00
afkarxyz 237ee777c3 v6.8 2025-12-14 12:22:08 +07:00
afkarxyz b44a9abdd6 v6.8 2025-12-14 07:47:18 +07:00
afkarxyz 6c3fb13b25 v6.8 2025-12-13 18:32:50 +07:00
afkarxyz 9398e496e8 v6.8 2025-12-13 18:31:11 +07:00
afkarxyz a8d6276d00 v6.8 2025-12-13 16:42:29 +07:00
afkarxyz 4d3aac4990 v6.8 2025-12-13 14:28:08 +07:00
afkarxyz 64d7f82e52 v6.8 2025-12-13 13:59:41 +07:00
afkarxyz 910420634c v6.8 2025-12-13 13:36:36 +07:00
afkarxyz 22742f1ddd v6.8 2025-12-13 13:32:09 +07:00
afkarxyz 5c1d6619b5 v6.8 2025-12-13 11:43:17 +07:00
Sepehr Aroofzade 76669f551e Add FLAC lyrics embedding with LRCLIB fallback (#151)
* Update lyrics code

* Refactor DownloadFile to use default HTTP client
2025-12-13 04:09:22 +07:00
enriqueqs ffd4daf031 fixed path issue (#157)
Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
2025-12-12 17:31:13 +07:00
Heggo 64b86b65a1 Fix Linux Download Path (#153)
* Fix Unknown being appended at the beginning of Download Path on Unix/Linux systems

* Remove extra blank line in backend/filename.go
2025-12-12 17:27:09 +07:00
afkarxyz 8f10094e40 v6.7 2025-12-08 19:33:43 +07:00
afkarxyz 2fb544d1f8 v6.6 2025-12-05 05:26:33 +07:00
afkarxyz d16eaa324a v6.6 2025-12-05 05:25:50 +07:00
afkarxyz cc3f7640c6 v6.5 2025-11-30 05:38:44 +07:00
Lukas 2653586eea Download Queue & Progress UI (#123)
* Add download queue tracking and UI integration

Introduces backend support for a download queue with item tracking, status updates, and session statistics. Adds frontend components and hooks for displaying and managing the download queue, including a dialog and toast indicator. Updates download logic to pre-add items to the queue, track progress, and handle completion, skipping, and failure states. Integrates @radix-ui/react-scroll-area for improved UI scrolling.

* Add session stats to DownloadQueue dialog

Introduces session statistics (downloaded amount, speed, and duration) to the DownloadQueue dialog for improved user feedback. Also adjusts dialog sizing for better display and removes the sm:max-w-lg restriction in dialog.tsx for more flexible width.
2025-11-29 17:36:58 +07:00
afkarxyz 0c92385c56 v6.4 2025-11-27 14:14:19 +07:00
afkarxyz 957fb83dbc v6.4 2025-11-27 06:49:33 +07:00
afkarxyz 90f1871488 UPX 2025-11-27 04:58:53 +07:00
afkarxyz 2d0e5055f8 UPX 2025-11-27 04:52:16 +07:00
afkarxyz 08d02be4f2 v6.3 2025-11-26 10:55:08 +07:00
afkarxyz 5b542dcf29 v6.3 2025-11-26 10:48:26 +07:00
afkarxyz 48f9584027 v6.3 2025-11-26 10:47:02 +07:00
afkarxyz 4241a591aa Fallback Tidal with search and ISRC matching 2025-11-26 07:32:26 +07:00
afkarxyz f346fbb6ba Refactor README.md for badge updates and formatting 2025-11-26 04:51:34 +07:00
Lukas 2172981110 Add FLAC audio quality analysis and spectrum visualization (#110)
Introduces backend support for analyzing FLAC audio files, including technical metrics and frequency spectrum extraction. Adds frontend components and hooks for file selection, analysis, and visualization, integrating a new Audio Quality Analyzer dialog into the UI. Updates types and dependencies to support audio analysis features.
2025-11-26 04:05:12 +07:00
afkarxyz ddf1844237 v6.2 2025-11-25 13:25:29 +07:00
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
88 changed files with 13569 additions and 2673 deletions
+82 -34
View File
@@ -2,18 +2,13 @@ name: Build Multi-Platform
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
workflow_dispatch:
env:
GO_VERSION: '1.25.4'
NODE_VERSION: '20'
NODE_VERSION: '24'
jobs:
build-windows:
@@ -55,10 +50,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-
@@ -71,19 +67,27 @@ jobs:
pnpm install
pnpm run generate-icon
- name: Install UPX
run: |
choco install upx -y
- name: Build application
run: wails build -platform windows/amd64
- name: Compress with UPX
run: |
upx --best --lzma "build\bin\SpotiFLAC.exe"
- 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 +127,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 +162,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,17 +211,18 @@ 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-
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
@@ -233,10 +239,25 @@ jobs:
- name: Build application
run: wails build -platform linux/amd64
- name: Download appimagetool
- name: Compress with UPX
run: |
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool
upx --best --lzma build/bin/SpotiFLAC
- name: Cache appimagetool
id: cache-appimagetool
uses: actions/cache@v4
with:
path: appimagetool
key: appimagetool-x86_64-v1
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
- name: Make appimagetool executable
run: chmod +x appimagetool
- name: Create AppImage
run: |
@@ -245,36 +266,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 +303,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 +354,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
+3
View File
@@ -56,6 +56,9 @@ temp/
*.bak
*.old
# Test files
test
# Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md
build.txt
+15 -6
View File
@@ -4,10 +4,9 @@
<div align="center">
Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
<br><br>
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white)
@@ -17,12 +16,22 @@ Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
## Screenshot
![Image](https://github.com/user-attachments/assets/d8bfb49c-58be-4092-9bf4-6e6a5d4b944a)
![Image](https://github.com/user-attachments/assets/ee352566-7e6d-4d6f-9add-d6b55a0187fa)
## Lossless Audio Check
## Lossless Audio Checker
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 project
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
+537 -35
View File
@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"spotiflac/backend"
"strings"
"time"
)
@@ -34,25 +37,62 @@ 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"`
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"`
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"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"` // Spotify cover URL for embedding
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
EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file
EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` // Whether to embed max quality cover art
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
SpotifyTrackNumber int `json:"spotify_track_number,omitempty"` // Track number from Spotify album
SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"` // Disc number from Spotify album
SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"` // Total tracks in album from Spotify
}
// 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"`
ItemID string `json:"item_id,omitempty"` // Queue item ID for tracking
}
// 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
@@ -94,11 +134,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
if req.Service == "" {
req.Service = "deezer"
req.Service = "tidal"
}
if req.OutputDir == "" {
req.OutputDir = "."
} else {
// Sanitize output directory path to remove invalid characters
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
}
if req.AudioFormat == "" {
@@ -113,44 +156,226 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
req.FilenameFormat = "title-artist"
}
if req.Service == "tidal" {
searchQuery := req.Query
if searchQuery == "" {
searchQuery = req.ISRC
// ItemID should always be provided by frontend (created via AddToDownloadQueue)
// If not provided, generate one for backwards compatibility
itemID := req.ItemID
if itemID == "" {
itemID = fmt.Sprintf("%s-%d", req.ISRC, time.Now().UnixNano())
// Add to queue if no ItemID was provided (legacy support)
backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.ISRC)
}
// Mark item as downloading immediately
backend.SetDownloading(true)
backend.StartDownloadItem(itemID)
defer backend.SetDownloading(false)
// 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)
backend.SkipDownloadItem(itemID, existingFile)
return DownloadResponse{
Success: true,
Message: "File with same ISRC already exists",
File: existingFile,
AlreadyExists: true,
ItemID: itemID,
}, 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() > 100*1024 {
// Validate the file by checking if it has valid ISRC metadata
if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
// File exists and has valid metadata - skip download
backend.SkipDownloadItem(itemID, expectedPath)
return DownloadResponse{
Success: true,
Message: "File already exists",
File: expectedPath,
AlreadyExists: true,
ItemID: itemID,
}, nil
} else {
// File exists but has no valid ISRC metadata - it's corrupted, delete it
fmt.Printf("Removing corrupted file (no valid ISRC metadata): %s\n", expectedPath)
if removeErr := os.Remove(expectedPath); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", expectedPath, removeErr)
}
}
}
}
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.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
} 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.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
}
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, req.TrackName, req.ArtistName, req.AlbumName)
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.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Tidal",
}, fmt.Errorf("spotify ID is required for Tidal")
}
// Use ISRC matching for search fallback
filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
}
} else {
downloader := backend.NewTidalDownloader(req.ApiURL)
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
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.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Tidal",
}, fmt.Errorf("spotify ID is required for Tidal")
}
// Use ISRC matching for search fallback
filename, err = downloader.DownloadWithISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
}
}
} else if req.Service == "qobuz" {
case "qobuz":
downloader := backend.NewQobuzDownloader()
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
if err == nil {
filename = "Downloaded via Qobuz"
}
} else {
downloader := backend.NewDeezerDownloader()
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
if err == nil {
filename = "Downloaded via Deezer"
// Default to "6" (FLAC 16-bit) for Qobuz if not specified
quality := req.AudioFormat
if quality == "" {
quality = "6"
}
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
default:
return DownloadResponse{
Success: false,
Error: fmt.Sprintf("Unknown service: %s", req.Service),
}, fmt.Errorf("unknown service: %s", req.Service)
}
if err != nil {
// Clean up any partial/corrupted file that was created during failed download
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
// Check if file exists and delete it
if _, statErr := os.Stat(filename); statErr == nil {
fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename)
if removeErr := os.Remove(filename); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filename, removeErr)
}
}
}
// Don't mark as failed in backend - let the frontend handle it
// Frontend will call MarkDownloadItemFailed after all services are tried
return DownloadResponse{
Success: false,
Error: fmt.Sprintf("Download failed: %v", err),
ItemID: itemID,
}, err
}
// Check if file already existed
alreadyExists := false
if strings.HasPrefix(filename, "EXISTS:") {
alreadyExists = true
filename = strings.TrimPrefix(filename, "EXISTS:")
}
// Embed lyrics after successful download (only for new downloads with Spotify ID and if embedLyrics is enabled)
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") {
go func(filePath, spotifyID, trackName, artistName string) {
fmt.Printf("\n========== LYRICS FETCH START ==========\n")
fmt.Printf("Spotify ID: %s\n", spotifyID)
fmt.Printf("Track: %s\n", trackName)
fmt.Printf("Artist: %s\n", artistName)
fmt.Println("Searching all sources...")
lyricsClient := backend.NewLyricsClient()
// Try all sources with fallbacks
lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName)
if err != nil {
fmt.Printf("All sources failed: %v\n", err)
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
return
}
if lyricsResp == nil || len(lyricsResp.Lines) == 0 {
fmt.Println("No lyrics content found")
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
return
}
fmt.Printf("Lyrics found from: %s\n", source)
fmt.Printf("Sync type: %s\n", lyricsResp.SyncType)
fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines))
lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName)
if lyrics == "" {
fmt.Println("No lyrics content to embed")
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
return
}
// Show full lyrics in console for debugging
fmt.Printf("\n--- Full LRC Content ---\n")
fmt.Println(lyrics)
fmt.Printf("--- End LRC Content ---\n\n")
fmt.Printf("Embedding into: %s\n", filePath)
if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil {
fmt.Printf("Failed to embed lyrics: %v\n", err)
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
} else {
fmt.Printf("Lyrics embedded successfully!\n")
fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n")
}
}(filename, req.SpotifyID, req.TrackName, req.ArtistName)
}
message := "Download completed successfully"
if alreadyExists {
message = "File already exists"
backend.SkipDownloadItem(itemID, filename)
} else {
// Get file size for completed download
if fileInfo, statErr := os.Stat(filename); statErr == nil {
finalSize := float64(fileInfo.Size()) / (1024 * 1024) // Convert to MB
backend.CompleteDownloadItem(itemID, filename, finalSize)
} else {
// Fallback: mark as completed without size
backend.CompleteDownloadItem(itemID, filename, 0)
}
}
return DownloadResponse{
Success: true,
Message: "Download completed successfully",
File: filename,
Success: true,
Message: message,
File: filename,
AlreadyExists: alreadyExists,
ItemID: itemID,
}, nil
}
@@ -173,9 +398,286 @@ func (a *App) SelectFolder(defaultPath string) (string, error) {
return backend.SelectFolderDialog(a.ctx, defaultPath)
}
// SelectFile opens a file selection dialog and returns the selected file path
func (a *App) SelectFile() (string, error) {
return backend.SelectFileDialog(a.ctx)
}
// 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()
}
// GetDownloadQueue returns the complete download queue state
func (a *App) GetDownloadQueue() backend.DownloadQueueInfo {
return backend.GetDownloadQueue()
}
// ClearCompletedDownloads clears completed, failed, and skipped items from the queue
func (a *App) ClearCompletedDownloads() {
backend.ClearDownloadQueue()
}
// ClearAllDownloads clears the entire queue and resets session stats
func (a *App) ClearAllDownloads() {
backend.ClearAllDownloads()
}
// AddToDownloadQueue adds a new item to the download queue and returns its ID
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
return itemID
}
// MarkDownloadItemFailed marks a download item as failed
func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) {
backend.FailDownloadItem(itemID, errorMsg)
}
// CancelAllQueuedItems marks all queued items as cancelled/skipped
func (a *App) CancelAllQueuedItems() {
backend.CancelAllQueuedItems()
}
// 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
}
// AnalyzeTrack analyzes audio quality of a FLAC file
func (a *App) AnalyzeTrack(filePath string) (string, error) {
if filePath == "" {
return "", fmt.Errorf("file path is required")
}
result, err := backend.AnalyzeTrack(filePath)
if err != nil {
return "", fmt.Errorf("failed to analyze track: %v", err)
}
jsonData, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
// AnalyzeMultipleTracks analyzes multiple FLAC files
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
if len(filePaths) == 0 {
return "", fmt.Errorf("at least one file path is required")
}
results := make([]*backend.AnalysisResult, 0, len(filePaths))
for _, filePath := range filePaths {
result, err := backend.AnalyzeTrack(filePath)
if err != nil {
// Skip failed analyses
continue
}
results = append(results, result)
}
jsonData, err := json.Marshal(results)
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
// LyricsDownloadRequest represents the request structure for downloading lyrics
type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
}
// DownloadLyrics downloads lyrics for a single track
func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadResponse, error) {
if req.SpotifyID == "" {
return backend.LyricsDownloadResponse{
Success: false,
Error: "Spotify ID is required",
}, fmt.Errorf("spotify ID is required")
}
client := backend.NewLyricsClient()
backendReq := backend.LyricsDownloadRequest{
SpotifyID: req.SpotifyID,
TrackName: req.TrackName,
ArtistName: req.ArtistName,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
UseAlbumTrackNumber: req.UseAlbumTrackNumber,
}
resp, err := client.DownloadLyrics(backendReq)
if err != nil {
return backend.LyricsDownloadResponse{
Success: false,
Error: err.Error(),
}, err
}
return *resp, nil
}
// CoverDownloadRequest represents the request structure for downloading cover art
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
}
// DownloadCover downloads cover art for a single track
func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) {
if req.CoverURL == "" {
return backend.CoverDownloadResponse{
Success: false,
Error: "Cover URL is required",
}, fmt.Errorf("cover URL is required")
}
client := backend.NewCoverClient()
backendReq := backend.CoverDownloadRequest{
CoverURL: req.CoverURL,
TrackName: req.TrackName,
ArtistName: req.ArtistName,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
}
resp, err := client.DownloadCover(backendReq)
if err != nil {
return backend.CoverDownloadResponse{
Success: false,
Error: err.Error(),
}, err
}
return *resp, nil
}
// CheckTrackAvailability checks the availability of a track on different streaming platforms
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
client := backend.NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyTrackID, isrc)
if err != nil {
return "", err
}
jsonData, err := json.Marshal(availability)
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
// IsFFmpegInstalled checks if ffmpeg is installed
func (a *App) IsFFmpegInstalled() (bool, error) {
return backend.IsFFmpegInstalled()
}
// GetFFmpegPath returns the path to ffmpeg
func (a *App) GetFFmpegPath() (string, error) {
return backend.GetFFmpegPath()
}
// DownloadFFmpegRequest represents a request to download ffmpeg
type DownloadFFmpegRequest struct{}
// DownloadFFmpegResponse represents the response from downloading ffmpeg
type DownloadFFmpegResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
// DownloadFFmpeg downloads and installs ffmpeg
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
err := backend.DownloadFFmpeg(func(progress int) {
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress)
})
if err != nil {
return DownloadFFmpegResponse{
Success: false,
Error: err.Error(),
}
}
return DownloadFFmpegResponse{
Success: true,
Message: "FFmpeg installed successfully",
}
}
// InstallFFmpegFromFile installs ffmpeg from a local file path
func (a *App) InstallFFmpegFromFile(filePath string) DownloadFFmpegResponse {
err := backend.InstallFFmpegFromFile(filePath)
if err != nil {
return DownloadFFmpegResponse{
Success: false,
Error: err.Error(),
}
}
return DownloadFFmpegResponse{
Success: true,
Message: "FFmpeg installed successfully from file",
}
}
// ConvertAudioRequest represents a request to convert audio files
type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"`
}
// ConvertAudio converts audio files using ffmpeg
func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResult, error) {
backendReq := backend.ConvertAudioRequest{
InputFiles: req.InputFiles,
OutputFormat: req.OutputFormat,
Bitrate: req.Bitrate,
}
return backend.ConvertAudio(backendReq)
}
// SelectAudioFiles opens a file dialog to select audio files for conversion
func (a *App) SelectAudioFiles() ([]string, error) {
files, err := backend.SelectMultipleFiles(a.ctx)
if err != nil {
return nil, err
}
return files, nil
}
+514
View File
@@ -0,0 +1,514 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"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()
// Read body first to handle encoding issues and provide better error messages
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("API returned empty response")
}
var songLinkResp SongLinkResponse
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
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, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover 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, false)
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
}
// Rename file based on Spotify metadata
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
// Build filename based on format settings
var newFilename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
}
} else {
// Legacy format support
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 (legacy behavior)
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)
}
}
// Embed Spotify metadata (replace Amazon's embedded metadata)
fmt.Println("Embedding Spotify metadata...")
coverPath := ""
// Download Spotify cover (with max resolution if enabled)
if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
// Determine track number to embed
// Use Spotify track number (album track number) if available, otherwise use position
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = position // Fallback to playlist position
}
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
}
// Build metadata from Spotify
metadata := Metadata{
Title: spotifyTrackName,
Artist: spotifyArtistName,
Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
ISRC: spotifyISRC, // Use ISRC from Spotify
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Println("Metadata embedded successfully")
}
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, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover 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, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
}
+181
View File
@@ -0,0 +1,181 @@
package backend
import (
"fmt"
"math"
"os"
"github.com/go-flac/go-flac"
mewflac "github.com/mewkiz/flac"
)
// AnalysisResult contains the audio analysis data
type AnalysisResult struct {
FilePath string `json:"file_path"`
SampleRate uint32 `json:"sample_rate"`
Channels uint8 `json:"channels"`
BitsPerSample uint8 `json:"bits_per_sample"`
TotalSamples uint64 `json:"total_samples"`
Duration float64 `json:"duration"`
BitDepth string `json:"bit_depth"`
DynamicRange float64 `json:"dynamic_range"`
PeakAmplitude float64 `json:"peak_amplitude"`
RMSLevel float64 `json:"rms_level"`
Spectrum *SpectrumData `json:"spectrum,omitempty"`
}
// AnalyzeTrack performs audio analysis on a FLAC file
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
// Parse FLAC file
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
result := &AnalysisResult{
FilePath: filepath,
}
// Extract basic audio properties from STREAMINFO block
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
// Read STREAMINFO data
data := streamInfo.Data
if len(data) >= 18 {
// Sample rate (bits 10-29 of bytes 10-13)
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
// Channels (bits 30-32 of byte 12)
result.Channels = ((data[12] >> 1) & 0x07) + 1
// Bits per sample (bits 33-37 of bytes 12-13)
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
// Total samples (bits 38-73 of bytes 13-17)
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
// Calculate duration
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
// Read min/max frame size and block size for additional analysis
// Min block size (bytes 0-1)
// Max block size (bytes 2-3)
// These can give us hints about encoding quality
}
}
}
// Analyze spectrum and calculate real audio metrics
spectrum, err := AnalyzeSpectrum(filepath)
if err != nil {
// Log error but continue
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else {
result.Spectrum = spectrum
// Calculate dynamic range, peak, and RMS from decoded samples
calculateRealAudioMetrics(result, filepath)
}
// Set bit depth
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
// Decode FLAC to get actual samples
samples, err := decodeFLACForMetrics(filepath)
if err != nil {
return
}
// Calculate peak amplitude
var peak float64
var sumSquares float64
for _, sample := range samples {
absVal := sample
if absVal < 0 {
absVal = -absVal
}
if absVal > peak {
peak = absVal
}
sumSquares += sample * sample
}
// Convert peak to dB (reference: 1.0 = 0 dBFS)
peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB
// Calculate RMS (Root Mean Square)
rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB
// Dynamic range is the difference between peak and RMS
result.DynamicRange = peakDB - rmsDB
}
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath)
if err != nil {
return nil, err
}
defer stream.Close()
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
maxSamples := 10000000
samples := make([]float64, 0, maxSamples)
// Read all audio frames
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
// Get samples from first channel (mono or left channel)
var channelSamples []int32
if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples
}
// Normalize samples to -1.0 to 1.0 range
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
for _, sample := range channelSamples {
if len(samples) >= maxSamples {
return samples, nil
}
normalized := float64(sample) / maxVal
samples = append(samples, normalized)
}
if len(samples) >= maxSamples {
break
}
}
return samples, nil
}
func GetFileSize(filepath string) (int64, error) {
info, err := os.Stat(filepath)
if err != nil {
return 0, err
}
return info.Size(), nil
}
+236
View File
@@ -0,0 +1,236 @@
package backend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
// Spotify image size codes
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySizeMax = "ab67616d000082c1" // Max resolution
)
// CoverDownloadRequest represents a request to download cover art
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
}
// CoverDownloadResponse represents the response from cover download
type CoverDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
// CoverClient handles cover art downloading
type CoverClient struct {
httpClient *http.Client
}
// NewCoverClient creates a new cover client
func NewCoverClient() *CoverClient {
return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// buildCoverFilename builds the cover filename based on settings (same as track filename)
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
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 (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
}
return filename + ".jpg"
}
// getMaxResolutionURL converts a Spotify cover URL to max resolution
// Falls back to original URL if max resolution is not available
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
// Try to convert to max resolution
if strings.Contains(coverURL, spotifySize640) {
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Check if max resolution URL is available
resp, err := c.httpClient.Head(maxURL)
if err == nil && resp.StatusCode == http.StatusOK {
return maxURL
}
}
// Return original URL as fallback
return coverURL
}
// DownloadCoverToPath downloads cover art from URL to a specific path
// If embedMaxQualityCover is true, it will try to get max resolution
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
if coverURL == "" {
return fmt.Errorf("cover URL is required")
}
// Use max quality URL if setting is enabled
downloadURL := coverURL
if embedMaxQualityCover {
downloadURL = c.getMaxResolutionURL(coverURL)
}
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("failed to download cover: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
}
// Create file
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %v", err)
}
defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write cover file: %v", err)
}
return nil
}
// DownloadCover downloads cover art for a single track
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" {
return &CoverDownloadResponse{
Success: false,
Error: "Cover URL is required",
}, fmt.Errorf("cover URL is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create output directory: %v", err),
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
}
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{
Success: true,
Message: "Cover file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
// Try to get max resolution URL, fallback to original
downloadURL := c.getMaxResolutionURL(req.CoverURL)
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download cover: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Create file
file, err := os.Create(filePath)
if err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write cover file: %v", err),
}, err
}
return &CoverDownloadResponse{
Success: true,
Message: "Cover downloaded successfully",
File: filePath,
}, nil
}
-277
View File
@@ -1,277 +0,0 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type DeezerDownloader struct {
client *http.Client
}
type DeezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
TitleShort string `json:"title_short"`
Duration int `json:"duration"`
TrackPos int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
ReleaseDate string `json:"release_date"`
Artist struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"artist"`
Album struct {
Title string `json:"title"`
ID int64 `json:"id"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
} `json:"album"`
Contributors []struct {
Name string `json:"name"`
Role string `json:"role"`
} `json:"contributors"`
}
type DeezMateResponse struct {
Success bool `json:"success"`
Links struct {
FLAC string `json:"flac"`
} `json:"links"`
}
func NewDeezerDownloader() *DeezerDownloader {
return &DeezerDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=")
url := fmt.Sprintf("%s%s", string(apiBase), isrc)
resp, err := d.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch track: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var track DeezerTrack
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if track.ID == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
}
return &track, nil
}
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
// 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 {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
var apiResp DeezMateResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return "", fmt.Errorf("failed to decode API response: %w", err)
}
if !apiResp.Success || apiResp.Links.FLAC == "" {
return "", fmt.Errorf("no FLAC download link available")
}
return apiResp.Links.FLAC, nil
}
func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
resp, err := d.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)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
resp, err := d.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 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 {
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 && trackNumber > 0 {
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
}
return filename + ".flac"
}
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) error {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
track, err := d.GetTrackByISRC(isrc)
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 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
}
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
// Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber)
filepath := filepath.Join(outputDir, filename)
fmt.Println("Downloading FLAC file...")
if err := d.DownloadFile(downloadURL, filepath); err != nil {
return err
}
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.CoverXL != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := d.DownloadCoverArt(track.Album.CoverXL, 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...")
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: track.ReleaseDate,
TrackNumber: track.TrackPos,
DiscNumber: track.DiskNumber,
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 nil
}
+557
View File
@@ -0,0 +1,557 @@
package backend
import (
"archive/tar"
"archive/zip"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/ulikunitz/xz"
)
// decodeBase64 decodes a base64 encoded string
func decodeBase64(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
return string(decoded), nil
}
const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZm1wZWcvemlw"
)
// GetFFmpegDir returns the directory where ffmpeg should be stored
func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".spotiflac"), nil
}
// GetFFmpegPath returns the full path to the ffmpeg executable
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
return filepath.Join(ffmpegDir, ffmpegName), nil
}
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
_, err = os.Stat(ffmpegPath)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, err
}
// Verify it's executable
cmd := exec.Command(ffmpegPath, "-version")
// Hide console window on Windows
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
func DownloadFFmpeg(progressCallback func(int)) error {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return err
}
// Create directory if it doesn't exist
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
// Get the appropriate URL for the current OS
var encodedURL string
switch runtime.GOOS {
case "windows":
encodedURL = ffmpegWindowsURL
case "linux":
encodedURL = ffmpegLinuxURL
case "darwin":
encodedURL = ffmpegMacOSURL
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
// Decode URL
url, err := decodeBase64(encodedURL)
if err != nil {
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
}
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
// Create temporary file for download
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Download the file
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download ffmpeg: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download ffmpeg: HTTP %d", resp.StatusCode)
}
totalSize := resp.ContentLength
var downloaded int64
// Create a progress reader
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, writeErr := tmpFile.Write(buf[:n])
if writeErr != nil {
return fmt.Errorf("failed to write to temp file: %w", writeErr)
}
downloaded += int64(n)
if totalSize > 0 && progressCallback != nil {
progress := int(float64(downloaded) / float64(totalSize) * 100)
progressCallback(progress)
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
}
tmpFile.Close()
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
// Extract the archive
switch runtime.GOOS {
case "windows", "darwin":
return extractZip(tmpFile.Name(), ffmpegDir)
case "linux":
return extractTarXz(tmpFile.Name(), ffmpegDir)
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
}
// extractZip extracts ffmpeg from a zip archive
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer r.Close()
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
destPath := filepath.Join(destDir, ffmpegName)
for _, f := range r.File {
// Look for ffmpeg executable in any subdirectory
baseName := filepath.Base(f.Name)
if baseName == ffmpegName && !f.FileInfo().IsDir() {
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open file in zip: %w", err)
}
defer rc.Close()
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
_, err = io.Copy(outFile, rc)
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
return nil
}
}
return fmt.Errorf("ffmpeg executable not found in archive")
}
// extractTarXz extracts ffmpeg from a tar.xz archive
func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath)
if err != nil {
return fmt.Errorf("failed to open tar.xz: %w", err)
}
defer file.Close()
xzReader, err := xz.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create xz reader: %w", err)
}
tarReader := tar.NewReader(xzReader)
ffmpegName := "ffmpeg"
destPath := filepath.Join(destDir, ffmpegName)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
baseName := filepath.Base(header.Name)
if baseName == ffmpegName && header.Typeflag == tar.TypeReg {
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
_, err = io.Copy(outFile, tarReader)
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
return nil
}
}
return fmt.Errorf("ffmpeg executable not found in archive")
}
// ConvertAudioRequest represents a request to convert audio files
type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"` // mp3, m4a
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k"
}
// ConvertAudioResult represents the result of a single file conversion
type ConvertAudioResult struct {
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// ConvertAudio converts audio files using ffmpeg while preserving metadata
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
}
installed, err := IsFFmpegInstalled()
if err != nil || !installed {
return nil, fmt.Errorf("ffmpeg is not installed")
}
results := make([]ConvertAudioResult, len(req.InputFiles))
var wg sync.WaitGroup
var mu sync.Mutex
// Convert files in parallel
for i, inputFile := range req.InputFiles {
wg.Add(1)
go func(idx int, inputFile string) {
defer wg.Done()
result := ConvertAudioResult{
InputFile: inputFile,
}
// Get input file info
inputExt := strings.ToLower(filepath.Ext(inputFile))
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
inputDir := filepath.Dir(inputFile)
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
outputFormatUpper := strings.ToUpper(req.OutputFormat)
outputDir := filepath.Join(inputDir, outputFormatUpper)
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0755); err != nil {
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
// Determine output path
outputExt := "." + strings.ToLower(req.OutputFormat)
outputFile := filepath.Join(outputDir, baseName+outputExt)
// Skip if same format
if inputExt == outputExt {
result.Error = "Input and output formats are the same"
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
result.OutputFile = outputFile
// Extract cover art and lyrics from input file before conversion
var coverArtPath string
var lyrics string
coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
} else if lyrics != "" {
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
} else {
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
}
// Build ffmpeg command
args := []string{
"-i", inputFile,
"-y", // Overwrite output
}
// Add codec and bitrate based on output format
switch req.OutputFormat {
case "mp3":
args = append(args,
"-codec:a", "libmp3lame",
"-b:a", req.Bitrate,
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
)
// Map video stream if exists (for cover art)
args = append(args, "-map", "0:v?", "-c:v", "copy")
case "m4a":
args = append(args,
"-codec:a", "aac",
"-b:a", req.Bitrate,
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
)
// Map video stream for cover art in M4A
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
}
args = append(args, outputFile)
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
cmd := exec.Command(ffmpegPath, args...)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
// Clean up temp cover art file if exists
if coverArtPath != "" {
os.Remove(coverArtPath)
}
return
}
// Embed cover art and lyrics after conversion if they were extracted
if coverArtPath != "" {
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
}
os.Remove(coverArtPath) // Clean up temp file
}
if lyrics != "" {
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
}
}
result.Success = true
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, inputFile)
}
wg.Wait()
return results, nil
}
// GetAudioInfo returns information about an audio file
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
Format string `json:"format"`
Size int64 `json:"size"`
}
// GetAudioFileInfo gets information about an audio file
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
info, err := os.Stat(filePath)
if err != nil {
return nil, err
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
return &AudioFileInfo{
Path: filePath,
Filename: filepath.Base(filePath),
Format: ext,
Size: info.Size(),
}, nil
}
// InstallFFmpegFromFile installs ffmpeg from a local file path
func InstallFFmpegFromFile(filePath string) error {
// Check if file exists
info, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("file does not exist: %w", err)
}
// Check if it's a regular file (not a directory)
if info.IsDir() {
return fmt.Errorf("path is a directory, not a file")
}
// Verify it's likely an ffmpeg executable by checking the filename
fileName := strings.ToLower(filepath.Base(filePath))
expectedName := "ffmpeg"
if runtime.GOOS == "windows" {
expectedName = "ffmpeg.exe"
}
if fileName != expectedName && !strings.Contains(fileName, "ffmpeg") {
return fmt.Errorf("file does not appear to be an ffmpeg executable (expected name containing 'ffmpeg')")
}
// Get destination path
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("failed to get ffmpeg path: %w", err)
}
ffmpegDir := filepath.Dir(ffmpegPath)
// Create directory if it doesn't exist
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
// Copy file to destination
sourceFile, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
destFile, err := os.OpenFile(ffmpegPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
sourceFile.Close()
return fmt.Errorf("failed to create destination file: %w", err)
}
_, err = io.Copy(destFile, sourceFile)
sourceFile.Close()
if err != nil {
destFile.Close()
return fmt.Errorf("failed to copy file: %w", err)
}
// Ensure all data is written to disk
if err := destFile.Sync(); err != nil {
destFile.Close()
return fmt.Errorf("failed to sync file: %w", err)
}
destFile.Close()
// On Windows, file may still be locked by antivirus or system
// Wait a bit and retry verification
maxRetries := 3
retryDelay := 500 * time.Millisecond
var verifyErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
time.Sleep(retryDelay)
}
cmd := exec.Command(ffmpegPath, "-version")
// Hide console window on Windows
setHideWindow(cmd)
verifyErr = cmd.Run()
if verifyErr == nil {
break
}
}
if verifyErr != nil {
return fmt.Errorf("file copied but ffmpeg verification failed after %d attempts: %w", maxRetries, verifyErr)
}
fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath)
return nil
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
// +build !windows
package backend
import (
"os/exec"
)
// setHideWindow is a no-op on non-Windows platforms
func setHideWindow(cmd *exec.Cmd) {
// No-op on Unix-like systems
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
// +build windows
package backend
import (
"os/exec"
"syscall"
)
// setHideWindow sets HideWindow attribute for Windows processes
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}
+52
View File
@@ -0,0 +1,52 @@
package backend
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// SelectMultipleFiles opens a file dialog to select multiple audio files
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
Pattern: "*.mp3;*.m4a;*.flac",
},
{
DisplayName: "MP3 Files (*.mp3)",
Pattern: "*.mp3",
},
{
DisplayName: "M4A Files (*.m4a)",
Pattern: "*.m4a",
},
{
DisplayName: "FLAC Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
if err != nil {
return nil, err
}
return files, nil
}
// SelectOutputDirectory opens a directory dialog to select output folder
func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory",
})
if err != nil {
return "", err
}
return dir, nil
}
+163
View File
@@ -0,0 +1,163 @@
package backend
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// 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
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators like ". " or " - " or ". "
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
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 (legacy behavior)
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 {
// Replace forward slash with space (more natural than underscore)
sanitized := strings.ReplaceAll(name, "/", " ")
// Remove other invalid filesystem characters (replace with space)
re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ")
// Remove control characters (0x00-0x1F, 0x7F)
var result strings.Builder
for _, r := range sanitized {
// Keep printable characters and valid Unicode characters
// Remove control characters, but keep spaces, tabs, newlines for now
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
// Remove emoji and other symbols that might cause issues
// Keep letters, numbers, and common punctuation
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
// Remove emoji ranges (most emoji are in these ranges)
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
continue
}
result.WriteRune(r)
}
sanitized = result.String()
sanitized = strings.TrimSpace(sanitized)
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
sanitized = strings.Trim(sanitized, ". ")
// Normalize consecutive spaces to single space
re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ")
// Normalize consecutive underscores to single underscore
re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_")
// Remove leading/trailing underscores and spaces
sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" {
return "Unknown"
}
// Ensure the result is valid UTF-8
if !utf8.ValidString(sanitized) {
// If invalid UTF-8, try to fix it
sanitized = strings.ToValidUTF8(sanitized, "_")
}
return sanitized
}
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
// Detect separator
sep := string(filepath.Separator)
// Split path into components
parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts {
// Keep drive letter intact on Windows (e.g., "C:")
if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
if i == 0 && part == "" {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Sanitize each folder name (but don't replace / or \ since we already normalized)
sanitized := sanitizeFolderName(part)
if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized)
}
}
return strings.Join(sanitizedParts, sep)
}
// sanitizeFolderName removes invalid characters from a single folder name
func sanitizeFolderName(name string) string {
// Use the same sanitization as filename
return sanitizeFilename(name)
}
+28
View File
@@ -48,3 +48,31 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
return selectedPath, nil
}
func SelectFileDialog(ctx context.Context) (string, error) {
options := wailsRuntime.OpenDialogOptions{
Title: "Select FLAC File for Analysis",
Filters: []wailsRuntime.FileFilter{
{
DisplayName: "FLAC Audio Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
}
selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options)
if err != nil {
return "", err
}
// If user cancelled, selectedFile will be empty
if selectedFile == "" {
return "", nil
}
return selectedFile, nil
}
+420
View File
@@ -0,0 +1,420 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// LRCLibResponse represents the LRCLIB API response
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Duration float64 `json:"duration"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"`
}
// LyricsLine represents a single line of lyrics
type LyricsLine struct {
StartTimeMs string `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs string `json:"endTimeMs"`
}
// LyricsResponse represents the API response
type LyricsResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []LyricsLine `json:"lines"`
}
// LyricsDownloadRequest represents a request to download lyrics
type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
}
// LyricsDownloadResponse represents the response from lyrics download
type LyricsDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
// LyricsClient handles lyrics fetching
type LyricsClient struct {
httpClient *http.Client
}
// NewLyricsClient creates a new lyrics client
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
// Try LRCLIB API
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
url.QueryEscape(artistName),
url.QueryEscape(trackName))
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
}
var lrcLibResp LRCLibResponse
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
}
// Convert LRCLIB response to our LyricsResponse format
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
}
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
resp := &LyricsResponse{
Error: false,
SyncType: "LINE_SYNCED",
Lines: []LyricsLine{},
}
// Prefer synced lyrics, fall back to plain
lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics
resp.SyncType = "UNSYNCED"
}
if lyricsText == "" {
resp.Error = true
return resp
}
// Parse synced lyrics format [mm:ss.xx] text
lines := strings.Split(lyricsText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Check if line has timestamp [mm:ss.xx]
if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]")
if closeBracket > 0 {
timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:])
// Convert [mm:ss.xx] to milliseconds
ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
Words: words,
})
continue
}
}
// Plain lyrics line (no timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "0",
Words: line,
})
}
return resp
}
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
func lrcTimestampToMs(timestamp string) int64 {
var minutes, seconds, centiseconds int64
// Try parsing mm:ss.xx format
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return 0
}
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read failed: %v", err)
}
var results []LRCLibResponse
if err := json.Unmarshal(body, &results); err != nil {
return nil, fmt.Errorf("parse failed: %v", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("no results found")
}
// Find best match - prefer one with synced lyrics
var best *LRCLibResponse
for i := range results {
if results[i].SyncedLyrics != "" {
best = &results[i]
break
}
if best == nil && results[i].PlainLyrics != "" {
best = &results[i]
}
}
if best == nil {
best = &results[0]
}
return c.convertLRCLibToLyricsResponse(best), nil
}
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
func simplifyTrackName(name string) string {
// Remove content in parentheses
if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
// Remove content after " - " (like "From the Motion Picture")
if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
return name
}
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
// 1. Try LRCLIB exact match
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil
}
fmt.Printf(" LRCLIB exact: %v\n", err)
// 2. Try LRCLIB search
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search", nil
}
fmt.Printf(" LRCLIB search: %v\n", err)
// 3. Try with simplified track name (remove parentheses, subtitles)
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil
}
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search (simplified)", nil
}
}
return nil, "", fmt.Errorf("lyrics not found in any source")
}
// ConvertToLRC converts lyrics response to LRC format
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder
// Add metadata
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
sb.WriteString("[by:SpotiFlac]\n")
sb.WriteString("\n")
// Add lyrics lines
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
// Convert milliseconds to LRC timestamp format [mm:ss.xx]
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
}
return sb.String()
}
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
func msToLRCTimestamp(msStr string) string {
var ms int64
fmt.Sscanf(msStr, "%d", &ms)
totalSeconds := ms / 1000
minutes := totalSeconds / 60
seconds := totalSeconds % 60
centiseconds := (ms % 1000) / 10
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
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 (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
}
return filename + ".lrc"
}
// DownloadLyrics downloads lyrics for a single track
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
if req.SpotifyID == "" {
return &LyricsDownloadResponse{
Success: false,
Error: "Spotify ID is required",
}, fmt.Errorf("spotify ID is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create output directory: %v", err),
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &LyricsDownloadResponse{
Success: true,
Message: "Lyrics file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
// Fetch lyrics from LRCLIB
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: err.Error(),
}, err
}
// Convert to LRC format
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
// Write LRC file
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write LRC file: %v", err),
}, err
}
return &LyricsDownloadResponse{
Success: true,
Message: "Lyrics downloaded successfully",
File: filePath,
}, nil
}
+490 -1
View File
@@ -3,8 +3,12 @@ package backend
import (
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
@@ -14,10 +18,15 @@ type Metadata struct {
Title string
Artist string
Album string
Date string
AlbumArtist string
Date string // Recorded date (full date YYYY-MM-DD)
ReleaseDate string // Release date (full date) - kept for compatibility
TrackNumber int
TotalTracks int // Total tracks in album
DiscNumber int
ISRC string
Lyrics string
Description string
}
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
@@ -45,18 +54,31 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.Album != "" {
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
}
if metadata.AlbumArtist != "" {
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
}
if metadata.Date != "" {
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
}
if metadata.TrackNumber > 0 {
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
}
if metadata.TotalTracks > 0 {
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
}
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
// Lyrics is added last to keep it at the bottom
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced
}
cmtBlock := cmt.Marshal()
if cmtIdx < 0 {
@@ -111,3 +133,470 @@ func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// extractYear extracts the year from a release date string
// Handles formats: "YYYY-MM-DD", "YYYY-MM", "YYYY"
func extractYear(releaseDate string) string {
if releaseDate == "" {
return ""
}
// Try to extract year (first 4 digits)
if len(releaseDate) >= 4 {
return releaseDate[:4]
}
return releaseDate
}
// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata
func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
f, err := flac.ParseFile(filepath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx = -1
var existingCmt *flacvorbis.MetaDataBlockVorbisComment
for idx, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmtIdx = idx
existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
existingCmt = nil
}
break
}
}
// Create new comment block, preserving existing comments
cmt := flacvorbis.New()
// Copy existing comments except LYRICS
if existingCmt != nil {
for _, comment := range existingCmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" {
_ = cmt.Add(parts[0], parts[1])
}
}
}
}
// Add lyrics
_ = cmt.Add("LYRICS", lyrics)
cmtBlock := cmt.Marshal()
if cmtIdx < 0 {
f.Meta = append(f.Meta, &cmtBlock)
} else {
f.Meta[cmtIdx] = &cmtBlock
}
if err := f.Save(filepath); err != nil {
return fmt.Errorf("failed to save FLAC file: %w", err)
}
return 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 (this will fail for corrupted files)
isrc, err := ReadISRCFromFile(filepath)
if err != nil {
// File is corrupted or unreadable, delete it
fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
if removeErr := os.Remove(filepath); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
}
continue
}
// Compare ISRC (case-insensitive)
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
return filepath, true
}
}
return "", false
}
// ExtractCoverArt extracts cover art from an audio file and saves it to a temporary file
func ExtractCoverArt(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractCoverFromMp3(filePath)
case ".m4a", ".flac":
return extractCoverFromM4AOrFlac(filePath)
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
// extractCoverFromMp3 extracts cover art from MP3 file
func extractCoverFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
pictures := tag.GetFrames(tag.CommonID("Attached picture"))
if len(pictures) == 0 {
return "", fmt.Errorf("no cover art found")
}
pic, ok := pictures[0].(id3v2.PictureFrame)
if !ok {
return "", fmt.Errorf("invalid picture frame")
}
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.Picture); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
if ext == ".flac" {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.ImageData); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
}
return "", fmt.Errorf("no cover art found")
}
// For M4A, try to extract using ffmpeg or return empty
// M4A cover art should be preserved by ffmpeg during conversion
return "", nil
}
// ExtractLyrics extracts lyrics from an audio file
func ExtractLyrics(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractLyricsFromMp3(filePath)
case ".flac":
return extractLyricsFromFlac(filePath)
case ".m4a":
// M4A lyrics extraction would need different approach
return "", nil
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
// extractLyricsFromMp3 extracts lyrics from MP3 file
func extractLyricsFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
if len(usltFrames) == 0 {
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
return "", nil
}
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
if !ok {
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
return "", nil
}
if uslt.Lyrics == "" {
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
return "", nil
}
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
return uslt.Lyrics, nil
}
// extractLyricsFromFlac extracts lyrics from FLAC file
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
// Search through comments for lyrics
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
lyrics := parts[1]
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
return lyrics, nil
}
}
}
}
}
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
return "", nil
}
// EmbedCoverArtOnly embeds cover art into an audio file
func EmbedCoverArtOnly(filePath string, coverPath string) error {
if coverPath == "" || !fileExists(coverPath) {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return embedCoverToMp3(filePath, coverPath)
case ".m4a":
// M4A cover art should be handled by ffmpeg during conversion
// If not, we can try to embed using atomicparsley or similar tool
// For now, return nil as ffmpeg should handle it
return nil
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
// embedCoverToMp3 embeds cover art into MP3 file
func embedCoverToMp3(filePath string, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
// Remove existing cover art
tag.DeleteFrames(tag.CommonID("Attached picture"))
// Read cover art
artwork, err := os.ReadFile(coverPath)
if err != nil {
return fmt.Errorf("failed to read cover art: %w", err)
}
// Add new cover art
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Front cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
// Remove existing USLT frames
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
// Add new USLT frame with lyrics
// Use UTF-8 encoding for better compatibility with AIMP and other players
usltFrame := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding
Language: "eng",
ContentDescriptor: "", // Empty descriptor for better compatibility
Lyrics: lyrics,
}
tag.AddUnsynchronisedLyricsFrame(usltFrame)
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
func embedLyricsToM4A(filepath string, lyrics string) error {
// Use ffmpeg to embed lyrics into M4A file
// M4A uses iTunes metadata format with atom '©lyr' for lyrics
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
// Create temporary output file with proper extension so ffmpeg can detect format
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
defer func() {
// Only remove if file still exists (rename might have failed)
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
// Use ffmpeg to copy file and add lyrics metadata
// For M4A, we need to use the correct metadata tag format and specify output format
// Use -f ipod for M4A format (iPod format is compatible with M4A)
cmd := exec.Command(ffmpegPath,
"-i", filepath,
"-map", "0",
"-map_metadata", "0",
"-metadata", "lyrics-eng="+lyrics,
"-metadata", "lyrics="+lyrics,
"-codec", "copy",
"-f", "ipod", // Explicitly specify M4A/iPod format
"-y", // Overwrite
tmpOutputFile,
)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
}
// Replace original file with new file
if err := os.Rename(tmpOutputFile, filepath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
return nil
}
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(filepath))
switch ext {
case ".mp3":
return EmbedLyricsOnlyMP3(filepath, lyrics)
case ".flac":
return EmbedLyricsOnly(filepath, lyrics)
case ".m4a":
return embedLyricsToM4A(filepath, lyrics)
default:
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
}
}
+459
View File
@@ -0,0 +1,459 @@
package backend
import (
"fmt"
"io"
"sync"
"time"
)
// DownloadStatus represents the status of a download item
type DownloadStatus string
const (
StatusQueued DownloadStatus = "queued"
StatusDownloading DownloadStatus = "downloading"
StatusCompleted DownloadStatus = "completed"
StatusFailed DownloadStatus = "failed"
StatusSkipped DownloadStatus = "skipped"
)
// DownloadItem represents a single item in the download queue
type DownloadItem struct {
ID string `json:"id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
ISRC string `json:"isrc"`
Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"` // MB downloaded
TotalSize float64 `json:"total_size"` // MB total (if known)
Speed float64 `json:"speed"` // MB/s
StartTime int64 `json:"start_time"` // Unix timestamp
EndTime int64 `json:"end_time"` // Unix timestamp
ErrorMessage string `json:"error_message"` // If failed
FilePath string `json:"file_path"` // Final file path
}
// Global progress tracker
var (
currentProgress float64
currentProgressLock sync.RWMutex
isDownloading bool
downloadingLock sync.RWMutex
currentSpeed float64
speedLock sync.RWMutex
// Download queue tracking
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
currentItemLock sync.RWMutex
totalDownloaded float64
totalDownloadedLock sync.RWMutex
sessionStartTime int64
sessionStartLock 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"`
}
// DownloadQueueInfo represents the complete download queue state
type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"`
CurrentSpeed float64 `json:"current_speed"` // MB/s
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
QueuedCount int `json:"queued_count"`
CompletedCount int `json:"completed_count"`
FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"`
}
// 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
itemID string // Track which download item this belongs to
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
now := getCurrentTimeMillis()
return &ProgressWriter{
writer: writer,
total: 0,
lastPrinted: 0,
startTime: now,
lastTime: now,
lastBytes: 0,
itemID: "",
}
}
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer)
pw.itemID = itemID
return pw
}
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)
var speedMBps float64
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)
// Update individual item progress if we have an item ID
if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
}
pw.lastPrinted = pw.total
pw.lastTime = now
pw.lastBytes = pw.total
}
return n, err
}
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
// Queue management functions
// AddToQueue adds a new item to the download queue
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
item := DownloadItem{
ID: id,
TrackName: trackName,
ArtistName: artistName,
AlbumName: albumName,
ISRC: isrc,
Status: StatusQueued,
Progress: 0,
TotalSize: 0,
Speed: 0,
StartTime: 0,
EndTime: 0,
}
downloadQueue = append(downloadQueue, item)
// Initialize session start time if this is the first item
sessionStartLock.Lock()
if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix()
}
sessionStartLock.Unlock()
}
// StartDownloadItem marks an item as currently downloading
func StartDownloadItem(id string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusDownloading
downloadQueue[i].StartTime = time.Now().Unix()
downloadQueue[i].Progress = 0
break
}
}
currentItemLock.Lock()
currentItemID = id
currentItemLock.Unlock()
}
// UpdateItemProgress updates the progress of the current download item
func UpdateItemProgress(id string, progress, speed float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Progress = progress
downloadQueue[i].Speed = speed
break
}
}
}
// GetCurrentItemID returns the ID of the currently downloading item
func GetCurrentItemID() string {
currentItemLock.RLock()
defer currentItemLock.RUnlock()
return currentItemID
}
// CompleteDownloadItem marks an item as completed
func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusCompleted
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].FilePath = filePath
downloadQueue[i].Progress = finalSize
downloadQueue[i].TotalSize = finalSize
// Add to total downloaded
totalDownloadedLock.Lock()
totalDownloaded += finalSize
totalDownloadedLock.Unlock()
break
}
}
}
// FailDownloadItem marks an item as failed
func FailDownloadItem(id, errorMsg string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusFailed
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = errorMsg
break
}
}
}
// SkipDownloadItem marks an item as skipped (already exists)
func SkipDownloadItem(id, filePath string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].FilePath = filePath
break
}
}
}
// GetDownloadQueue returns the complete download queue state
func GetDownloadQueue() DownloadQueueInfo {
// Auto-reset session if all downloads are complete
ResetSessionIfComplete()
downloadQueueLock.RLock()
defer downloadQueueLock.RUnlock()
downloadingLock.RLock()
downloading := isDownloading
downloadingLock.RUnlock()
speedLock.RLock()
speed := currentSpeed
speedLock.RUnlock()
totalDownloadedLock.RLock()
total := totalDownloaded
totalDownloadedLock.RUnlock()
sessionStartLock.RLock()
sessionStart := sessionStartTime
sessionStartLock.RUnlock()
// Count statuses
var queued, completed, failed, skipped int
for _, item := range downloadQueue {
switch item.Status {
case StatusQueued:
queued++
case StatusCompleted:
completed++
case StatusFailed:
failed++
case StatusSkipped:
skipped++
}
}
// Create a copy of the queue
queueCopy := make([]DownloadItem, len(downloadQueue))
copy(queueCopy, downloadQueue)
return DownloadQueueInfo{
IsDownloading: downloading,
Queue: queueCopy,
CurrentSpeed: speed,
TotalDownloaded: total,
SessionStartTime: sessionStart,
QueuedCount: queued,
CompletedCount: completed,
FailedCount: failed,
SkippedCount: skipped,
}
}
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
func ClearDownloadQueue() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
// Keep only queued and downloading items
newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
newQueue = append(newQueue, item)
}
}
downloadQueue = newQueue
}
// ClearAllDownloads clears the entire queue and resets session stats
func ClearAllDownloads() {
downloadQueueLock.Lock()
downloadQueue = []DownloadItem{}
downloadQueueLock.Unlock()
totalDownloadedLock.Lock()
totalDownloaded = 0
totalDownloadedLock.Unlock()
sessionStartLock.Lock()
sessionStartTime = 0
sessionStartLock.Unlock()
currentItemLock.Lock()
currentItemID = ""
currentItemLock.Unlock()
// Reset current progress and speed
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
// CancelAllQueuedItems marks all queued items as skipped (cancelled)
// This is called when user stops a download or when batch download completes
func CancelAllQueuedItems() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].Status == StatusQueued {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = "Cancelled"
}
}
}
// ResetSessionIfComplete resets session stats if no active or queued downloads
// Note: Does NOT clear the queue - items remain visible for history
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
hasActiveOrQueued = true
break
}
}
downloadQueueLock.RUnlock()
// If no active or queued items, reset session stats
// But keep the queue items for history visibility
if !hasActiveOrQueued {
sessionStartLock.Lock()
sessionStartTime = 0
sessionStartLock.Unlock()
totalDownloadedLock.Lock()
totalDownloaded = 0
totalDownloadedLock.Unlock()
}
}
+130 -62
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -91,8 +93,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
var searchResp QobuzSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &searchResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(searchResp.Tracks.Items) == 0 {
@@ -105,15 +122,20 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
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
qualityCode := quality // Use the provided quality parameter
if qualityCode == "" {
qualityCode = "6" // Default to FLAC 16-bit if not specified
}
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
resp, err := q.client.Get(primaryURL)
if err == nil && resp.StatusCode == 200 {
@@ -149,12 +171,25 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("API returned empty response")
}
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)
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if streamResp.URL == "" {
@@ -167,7 +202,13 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
resp, err := q.client.Get(url)
// Use a separate client with a longer timeout. The default client's 60s limit
// causes downloads to fail on slow connections or for large Hi-Res files.
downloadClient := &http.Client{
Timeout: 5 * time.Minute, // 5 minutes for large files
}
resp, err := downloadClient.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -184,13 +225,16 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
}
defer out.Close()
fmt.Println("Writing file content...")
written, 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)
}
fmt.Printf("✓ Downloaded %d bytes\n", written)
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
@@ -219,65 +263,70 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err
}
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool) string {
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)
// Determine track number to use
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
// Add track number prefix if enabled
if includeTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
// Check if format is a template (contains {})
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist)
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
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 (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
}
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) error {
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (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)
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(isrc)
if err != nil {
return err
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
// All metadata from Spotify - no fallback to Qobuz
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)
@@ -290,11 +339,11 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
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)
return "", fmt.Errorf("failed to get download URL: %w", err)
}
if downloadURL == "" {
return fmt.Errorf("received empty download URL")
return "", fmt.Errorf("received empty download URL")
}
// Show partial URL for security
@@ -307,49 +356,68 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
// Build filename based on format settings
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber)
// Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings (use Spotify track number)
filename := buildQobuzFilename(safeTitle, safeArtist, spotifyTrackNumber, 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)
return "", fmt.Errorf("failed to download file: %w", err)
}
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.Image.Large != "" {
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
if spotifyCoverURL != "" {
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)
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
fmt.Println("Embedding metadata and cover art...")
releaseYear := ""
if len(track.ReleaseDateOriginal) >= 4 {
releaseYear = track.ReleaseDateOriginal[:4]
// Determine track number to embed - ALL from Spotify
trackNumberToEmbed := spotifyTrackNumber
if position > 0 && !useAlbumTrackNumber {
trackNumberToEmbed = position // Use playlist position
}
// ALL metadata from Spotify
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: releaseYear,
TrackNumber: track.TrackNumber,
DiscNumber: track.MediaNumber,
ISRC: track.ISRC,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
ISRC: isrc, // ISRC from Spotify (passed as parameter)
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
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
return filepath, nil
}
+222
View File
@@ -0,0 +1,222 @@
package backend
import (
"strings"
"unicode"
)
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
'や': "ya", 'ゆ': "yu", 'よ': "yo",
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
'わ': "wa", 'を': "wo", 'ん': "n",
// Dakuten (voiced)
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// Handakuten (semi-voiced)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "", // Double consonant marker
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", '': "no",
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
// Dakuten (voiced)
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// Handakuten (semi-voiced)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "", // Double consonant marker
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
// Extended katakana
'ー': "", // Long vowel mark
'ヴ': "vu",
}
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
}
var combinationKatakana = map[string]string{
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
"シャ": "sha", "シュ": "shu", "ショ": "sho",
"チャ": "cha", "チュ": "chu", "チョ": "cho",
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
// Extended combinations
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
}
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) {
return true
}
}
return false
}
func isHiragana(r rune) bool {
return r >= 0x3040 && r <= 0x309F
}
func isKatakana(r rune) bool {
return r >= 0x30A0 && r <= 0x30FF
}
func isKanji(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
}
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) {
return text
}
var result strings.Builder
runes := []rune(text)
i := 0
for i < len(runes) {
// Check for っ/ッ (double consonant)
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
nextRomaji := ""
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
nextRomaji = romaji
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
nextRomaji = romaji
}
if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0]) // Double the first consonant
}
i++
continue
}
// Check for two-character combinations
if i < len(runes)-1 {
combo := string(runes[i : i+2])
if romaji, ok := combinationHiragana[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
if romaji, ok := combinationKatakana[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
}
// Single character conversion
r := runes[i]
if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji)
} else if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji)
} else if isKanji(r) {
// Keep kanji as-is (would need dictionary for proper conversion)
result.WriteRune(r)
} else {
// Keep other characters (punctuation, spaces, etc.)
result.WriteRune(r)
}
i++
}
return result.String()
}
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName)
// Clean up the query - remove special characters that might interfere with search
trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji)
return strings.TrimSpace(artistClean + " " + trackClean)
}
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string {
var result strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
result.WriteRune(r)
} else if r == '-' || r == '\'' {
result.WriteRune(r)
}
}
return strings.TrimSpace(result.String())
}
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func cleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
// Keep only ASCII letters, numbers, spaces, and basic punctuation
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r)
} else if r == ',' || r == '.' {
// Convert punctuation to space
result.WriteRune(' ')
}
}
// Clean up multiple spaces
cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned)
}
+328
View File
@@ -0,0 +1,328 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"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"`
AmazonURL string `json:"amazon_url"`
}
// TrackAvailability represents the availability of a track on different platforms
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
}
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"`
}
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
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 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.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
return urls, nil
}
// CheckTrackAvailability checks the availability of a track on different platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, 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.Printf("Checking availability for track: %s\n", spotifyTrackID)
// 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 check availability: %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"`
}
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Qobuz using ISRC (song.link doesn't support Qobuz)
if isrc != "" {
qobuzAvailable := checkQobuzAvailability(isrc)
availability.Qobuz = qobuzAvailable
}
return availability, nil
}
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
resp, err := client.Get(searchURL)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var searchResp struct {
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return false
}
return searchResp.Tracks.Total > 0
}
+195
View File
@@ -0,0 +1,195 @@
package backend
import (
"fmt"
"math"
"math/cmplx"
"github.com/mewkiz/flac"
)
// SpectrumData contains frequency spectrum information
type SpectrumData struct {
TimeSlices []TimeSlice `json:"time_slices"`
SampleRate int `json:"sample_rate"`
FreqBins int `json:"freq_bins"`
Duration float64 `json:"duration"`
MaxFreq float64 `json:"max_freq"`
}
// TimeSlice represents spectrum data at a point in time
type TimeSlice struct {
Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"`
}
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
// Open FLAC file
stream, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
}
defer stream.Close()
info := stream.Info
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
// Read audio samples
samples, err := readSamples(stream, channels)
if err != nil {
return nil, fmt.Errorf("failed to read samples: %w", err)
}
if len(samples) == 0 {
return nil, fmt.Errorf("no audio samples found")
}
// Calculate spectrum
return calculateSpectrum(samples, sampleRate), nil
}
// readSamples reads and decodes audio samples from FLAC stream
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
var allSamples []float64
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
// Decode frames
for {
frame, err := stream.ParseNext()
if err != nil {
// End of stream
break
}
// Convert samples to float64 and mix channels to mono
for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64
// Mix all channels to mono by averaging
for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i])
}
sample /= float64(channels)
allSamples = append(allSamples, sample)
// Limit sample count
if len(allSamples) >= maxSamples {
return allSamples, nil
}
}
}
return allSamples, nil
}
// calculateSpectrum performs FFT analysis on audio samples
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
fftSize := 8192
numTimeSlices := 300
duration := float64(len(samples)) / float64(sampleRate)
samplesPerSlice := len(samples) / numTimeSlices
if samplesPerSlice < fftSize {
samplesPerSlice = fftSize
numTimeSlices = len(samples) / fftSize
}
timeSlices := make([]TimeSlice, 0, numTimeSlices)
freqBins := fftSize / 2
maxFreq := float64(sampleRate) / 2.0
for i := 0; i < numTimeSlices; i++ {
startIdx := i * samplesPerSlice
if startIdx+fftSize > len(samples) {
break
}
window := samples[startIdx : startIdx+fftSize]
windowedSamples := applyHannWindow(window)
spectrum := fft(windowedSamples)
magnitudes := make([]float64, freqBins)
for j := 0; j < freqBins; j++ {
magnitude := cmplx.Abs(spectrum[j])
if magnitude < 1e-10 {
magnitude = 1e-10
}
magnitudes[j] = 20 * math.Log10(magnitude)
}
timeSlice := TimeSlice{
Time: float64(startIdx) / float64(sampleRate),
Magnitudes: magnitudes,
}
timeSlices = append(timeSlices, timeSlice)
}
return &SpectrumData{
TimeSlices: timeSlices,
SampleRate: sampleRate,
FreqBins: freqBins,
Duration: duration,
MaxFreq: maxFreq,
}
}
// applyHannWindow applies Hann window to reduce spectral leakage
func applyHannWindow(samples []float64) []float64 {
n := len(samples)
windowed := make([]float64, n)
for i := 0; i < n; i++ {
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
windowed[i] = samples[i] * window
}
return windowed
}
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
func fft(samples []float64) []complex128 {
n := len(samples)
x := make([]complex128, n)
for i := 0; i < n; i++ {
x[i] = complex(samples[i], 0)
}
return fftRecursive(x)
}
// fftRecursive performs recursive FFT
func fftRecursive(x []complex128) []complex128 {
n := len(x)
if n <= 1 {
return x
}
even := make([]complex128, n/2)
odd := make([]complex128, n/2)
for i := 0; i < n/2; i++ {
even[i] = x[2*i]
odd[i] = x[2*i+1]
}
evenFFT := fftRecursive(even)
oddFFT := fftRecursive(odd)
result := make([]complex128, n)
for k := 0; k < n/2; k++ {
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
result[k] = evenFFT[k] + t
result[k+n/2] = evenFFT[k] - t
}
return result
}
+105 -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 (
@@ -57,29 +57,49 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
// TrackMetadata mirrors the filtered track payload returned by the Python script.
type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
}
// 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"`
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumType string `json:"album_type,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 +113,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 {
@@ -211,6 +233,7 @@ type trackSimplified struct {
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
}
@@ -220,6 +243,7 @@ type trackFull struct {
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ExternalURL externalURL `json:"external_urls"`
ExternalID externalID `json:"external_ids"`
Album albumSimplified `json:"album"`
@@ -269,11 +293,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 +338,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 +358,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,16 +496,38 @@ 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{
SpotifyID: item.Track.ID,
Artists: joinArtists(item.Track.Artists),
Name: item.Track.Name,
AlbumName: item.Track.Album.Name,
AlbumArtist: joinArtists(item.Track.Album.Artists),
DurationMS: item.Track.DurationMS,
Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images),
ReleaseDate: item.Track.Album.ReleaseDate,
TrackNumber: item.Track.TrackNumber,
TotalTracks: item.Track.Album.TotalTracks,
DiscNumber: item.Track.DiscNumber,
ExternalURL: item.Track.ExternalURL.Spotify,
ISRC: item.Track.ExternalID.ISRC,
AlbumID: item.Track.Album.ID,
AlbumURL: item.Track.Album.ExternalURL.Spotify,
ArtistID: artistID,
ArtistURL: artistURL,
ArtistsData: artistsData,
})
}
@@ -498,12 +539,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))
@@ -514,13 +562,17 @@ func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumR
for _, item := range raw.Data.Tracks.Items {
isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache)
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.ID,
Artists: joinArtists(item.Artists),
Name: item.Name,
AlbumName: raw.Data.Name,
AlbumArtist: joinArtists(raw.Data.Artists),
DurationMS: item.DurationMS,
Images: albumImage,
ReleaseDate: raw.Data.ReleaseDate,
TrackNumber: item.TrackNumber,
TotalTracks: raw.Data.TotalTracks,
DiscNumber: item.DiscNumber,
ExternalURL: item.ExternalURL.Spotify,
ISRC: isrc,
})
@@ -577,17 +629,39 @@ 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{
SpotifyID: tr.ID,
Artists: joinArtists(tr.Artists),
Name: tr.Name,
AlbumName: alb.Name,
AlbumArtist: joinArtists(alb.Artists),
AlbumType: alb.AlbumType,
DurationMS: tr.DurationMS,
Images: albumImage,
ReleaseDate: alb.ReleaseDate,
TrackNumber: tr.TrackNumber,
TotalTracks: alb.TotalTracks,
DiscNumber: tr.DiscNumber,
ExternalURL: tr.ExternalURL.Spotify,
ISRC: isrc,
AlbumID: alb.ID,
AlbumURL: alb.ExternalURL.Spotify,
ArtistID: artistID,
ArtistURL: artistURL,
ArtistsData: artistsData,
})
}
}
@@ -619,13 +693,17 @@ func formatTrackData(raw *trackFull) TrackResponse {
}
return TrackResponse{
Track: TrackMetadata{
SpotifyID: raw.ID,
Artists: joinArtists(raw.Artists),
Name: raw.Name,
AlbumName: raw.Album.Name,
AlbumArtist: joinArtists(raw.Album.Artists),
DurationMS: raw.DurationMS,
Images: firstImageURL(raw.Album.Images),
ReleaseDate: raw.Album.ReleaseDate,
TrackNumber: raw.TrackNumber,
TotalTracks: raw.Album.TotalTracks,
DiscNumber: raw.DiscNumber,
ExternalURL: raw.ExternalURL.Spotify,
ISRC: raw.ExternalID.ISRC,
},
@@ -873,8 +951,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)
+1217 -139
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<title>SpotiFLAC</title>
</head>
<body>
+18 -12
View File
@@ -6,45 +6,51 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"postinstall": "node scripts/generate-icon.js",
"lint": "eslint .",
"preview": "vite preview",
"generate-icon": "node scripts/generate-icon.js"
},
"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",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17"
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.2.4"
"typescript-eslint": "^8.49.0",
"vite": "^7.2.7"
}
}
+1 -1
View File
@@ -1 +1 @@
e00813ca84dd3deaade9854c0df093cd
d4b3974abd992c8ff941c6fde9f62062
+736 -672
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+410 -93
View File
@@ -5,48 +5,72 @@ import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { Search, X } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, applyThemeMode } from "@/lib/settings";
import { getSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
// Components
import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { SearchBar } from "@/components/SearchBar";
import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo";
import { PlaylistInfo } from "@/components/PlaylistInfo";
import { ArtistInfo } from "@/components/ArtistInfo";
import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { AudioConverterPage } from "@/components/AudioConverterPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import type { HistoryItem } from "@/components/FetchHistory";
// Hooks
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5;
function App() {
const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<string>("default");
const [currentPage, setCurrentPage] = useState(1);
const [currentListPage, setCurrentListPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "5.7";
const CURRENT_VERSION = "6.8";
const download = useDownload();
const metadata = useMetadata();
const lyrics = useLyrics();
const cover = useCover();
const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog();
useEffect(() => {
const settings = getSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
applyFont(settings.fontFamily);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
@@ -59,6 +83,7 @@ function App() {
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
loadHistory();
return () => {
mediaQuery.removeEventListener("change", handleChange);
@@ -69,19 +94,26 @@ function App() {
setSelectedTracks([]);
setSearchQuery("");
download.resetDownloadedTracks();
lyrics.resetLyricsState();
cover.resetCoverState();
availability.clearAvailability();
setSortBy("default");
setCurrentPage(1);
setCurrentListPage(1);
}, [metadata.metadata]);
const checkForUpdates = async () => {
try {
const response = await fetch(
"https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json"
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
);
const data = await response.json();
const latestVersion = data.version;
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
if (latestVersion > CURRENT_VERSION) {
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
}
} catch (err) {
@@ -89,6 +121,55 @@ function App() {
}
};
const loadHistory = () => {
try {
const saved = localStorage.getItem(HISTORY_KEY);
if (saved) {
setFetchHistory(JSON.parse(saved));
}
} catch (err) {
console.error("Failed to load history:", err);
}
};
const saveHistory = (history: HistoryItem[]) => {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
} catch (err) {
console.error("Failed to save history:", err);
}
};
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
setFetchHistory((prev) => {
const filtered = prev.filter((h) => h.url !== item.url);
const newItem: HistoryItem = {
...item,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
const updated = [newItem, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
};
const removeFromHistory = (id: string) => {
setFetchHistory((prev) => {
const updated = prev.filter((h) => h.id !== id);
saveHistory(updated);
return updated;
});
};
const handleHistorySelect = async (item: HistoryItem) => {
setSpotifyUrl(item.url);
const updatedUrl = await metadata.handleFetchMetadata(item.url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
};
const handleFetchMetadata = async () => {
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
if (updatedUrl) {
@@ -96,9 +177,57 @@ function App() {
}
};
useEffect(() => {
if (!metadata.metadata || !spotifyUrl) return;
let historyItem: Omit<HistoryItem, "id" | "timestamp"> | null = null;
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "track",
name: track.name,
artist: track.artists,
image: track.images,
};
} else if ("album_info" in metadata.metadata) {
const { album_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "album",
name: album_info.name,
artist: album_info.artists,
image: album_info.images,
};
} else if ("playlist_info" in metadata.metadata) {
const { playlist_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "playlist",
name: playlist_info.owner.name,
artist: `${playlist_info.tracks.total} tracks • ${playlist_info.owner.display_name}`,
image: playlist_info.owner.images || "",
};
} else if ("artist_info" in metadata.metadata) {
const { artist_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "artist",
name: artist_info.name,
artist: `${artist_info.total_albums} albums`,
image: artist_info.images,
};
}
if (historyItem) {
addToHistory(historyItem);
}
}, [metadata.metadata]);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
setCurrentPage(1);
setCurrentListPage(1);
};
const toggleTrackSelection = (isrc: string) => {
@@ -131,6 +260,7 @@ function App() {
}
};
const renderMetadata = () => {
if (!metadata.metadata) return null;
@@ -142,7 +272,19 @@ function App() {
isDownloading={download.isDownloading}
downloadingTrack={download.downloadingTrack}
isDownloaded={download.downloadedTracks.has(track.isrc)}
isFailed={download.failedTracks.has(track.isrc)}
isSkipped={download.skippedTracks.has(track.isrc)}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")}
failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")}
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
checkingAvailability={availability.checkingTrackId === track.spotify_id}
availability={availability.getAvailability(track.spotify_id || "")}
downloadingCover={cover.downloadingCover}
onDownload={download.handleDownloadTrack}
onDownloadLyrics={lyrics.handleDownloadLyrics}
onCheckAvailability={availability.checkAvailability}
onDownloadCover={cover.handleDownloadCover}
onOpenFolder={handleOpenFolder}
/>
);
@@ -158,25 +300,60 @@ function App() {
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
skippedTracks={download.skippedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage}
onPageChange={setCurrentListPage}
onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}}
onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}
/>
);
}
@@ -191,18 +368,41 @@ function App() {
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
skippedTracks={download.skippedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
onDownloadSelected={() =>
download.handleDownloadSelected(
@@ -213,7 +413,20 @@ function App() {
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage}
onPageChange={setCurrentListPage}
onAlbumClick={metadata.handleAlbumClick}
onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}}
onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}
/>
);
}
@@ -229,26 +442,61 @@ function App() {
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
failedTracks={download.failedTracks}
skippedTracks={download.skippedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onAlbumClick={metadata.handleAlbumClick}
onPageChange={setCurrentPage}
onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}}
onPageChange={setCurrentListPage}
onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}
/>
);
}
@@ -256,92 +504,161 @@ function App() {
return null;
};
return (
<TooltipProvider>
<div className="min-h-screen bg-background p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} />
{/* Timeout Dialog */}
<Dialog
open={metadata.showTimeoutDialog}
onOpenChange={metadata.setShowTimeoutDialog}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Fetch Settings</DialogTitle>
const renderPage = () => {
switch (currentPage) {
case "settings":
return <SettingsPage />;
case "debug":
return <DebugLoggerPage />;
case "audio-analysis":
return <AudioAnalysisPage />;
case "audio-converter":
return <AudioConverterPage />;
default:
return (
<>
<Header
version={CURRENT_VERSION}
hasUpdate={hasUpdate}
releaseDate={releaseDate}
/>
{/* Timeout Dialog */}
<Dialog
open={metadata.showTimeoutDialog}
onOpenChange={metadata.setShowTimeoutDialog}
>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={() => metadata.setShowTimeoutDialog(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
<DialogDescription>
Set timeout for fetching metadata. Longer timeout is recommended for artists
with large discography.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input
id="timeout"
type="number"
min="10"
max="600"
value={metadata.timeoutValue}
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes).
</p>
{metadata.pendingArtistName && (
<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
</div>
)}
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input
id="timeout"
type="number"
min="10"
max="600"
value={metadata.timeoutValue}
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes).
</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => metadata.setShowTimeoutDialog(false)}
>
Cancel
</Button>
<Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4 mr-2" />
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button
variant="outline"
onClick={() => metadata.setShowTimeoutDialog(false)}
>
Cancel
</Button>
<Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4" />
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Album Fetch Dialog */}
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Fetch Album</DialogTitle>
{/* Album Fetch Dialog */}
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={() => metadata.setShowAlbumDialog(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
<DialogDescription>
Do you want to fetch metadata for this album?
</DialogDescription>
</DialogHeader>
{metadata.selectedAlbum && (
<div className="py-4">
<p className="font-medium">{metadata.selectedAlbum.name}</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
Cancel
</Button>
<Button onClick={metadata.handleConfirmAlbumFetch}>
<Search className="h-4 w-4 mr-2" />
Fetch Album
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{metadata.selectedAlbum && (
<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
Cancel
</Button>
<Button onClick={async () => {
const albumUrl = await metadata.handleConfirmAlbumFetch();
if (albumUrl) {
setSpotifyUrl(albumUrl);
}
}}>
<Search className="h-4 w-4" />
Fetch Album
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SearchBar
url={spotifyUrl}
loading={metadata.loading}
onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata}
/>
<SearchBar
url={spotifyUrl}
loading={metadata.loading}
onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata}
history={fetchHistory}
onHistorySelect={handleHistorySelect}
onHistoryRemove={removeFromHistory}
hasResult={!!metadata.metadata}
/>
{metadata.metadata && renderMetadata()}
{metadata.metadata && renderMetadata()}
</>
);
}
};
return (
<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col">
<TitleBar />
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
{/* Main content area with sidebar offset */}
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{renderPage()}
</div>
</div>
{/* Download Progress Toast - Bottom Left */}
<DownloadProgressToast onClick={downloadQueue.openQueue} />
{/* Download Queue Dialog */}
<DownloadQueue
isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue}
/>
</div>
</TooltipProvider>
);
+119 -12
View File
@@ -1,11 +1,12 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
@@ -14,12 +15,16 @@ interface AlbumInfoProps {
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;
@@ -27,16 +32,38 @@ interface AlbumInfoProps {
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
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) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => 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({
@@ -46,6 +73,8 @@ export function AlbumInfo({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
@@ -53,16 +82,35 @@ export function AlbumInfo({
currentDownloadInfo,
currentPage,
itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onArtistClick,
onTrackClick,
}: AlbumInfoProps) {
return (
<div className="space-y-6">
@@ -81,19 +129,30 @@ export function AlbumInfo({
<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">
<span className="font-medium">{albumInfo.artists}</span>
{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}
className="gap-2"
disabled={isDownloading}
>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
@@ -105,7 +164,6 @@ export function AlbumInfo({
<Button
onClick={onDownloadSelected}
variant="secondary"
className="gap-2"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
@@ -116,8 +174,40 @@ export function AlbumInfo({
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
@@ -147,16 +237,33 @@ export function AlbumInfo({
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}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onTrackClick={onTrackClick}
/>
</div>
</div>
+112 -14
View File
@@ -1,11 +1,12 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface ArtistInfoProps {
artistInfo: {
@@ -27,6 +28,8 @@ interface ArtistInfoProps {
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
@@ -34,17 +37,39 @@ interface ArtistInfoProps {
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
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) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => 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({
@@ -55,6 +80,8 @@ export function ArtistInfo({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
@@ -62,17 +89,36 @@ export function ArtistInfo({
currentDownloadInfo,
currentPage,
itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onAlbumClick,
onArtistClick,
onPageChange,
onTrackClick,
}: ArtistInfoProps) {
return (
<div className="space-y-6">
@@ -89,8 +135,12 @@ export function ArtistInfo({
<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">
<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>
@@ -140,15 +190,10 @@ export function ArtistInfo({
{trackList.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">Popular Tracks</h3>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
size="sm"
className="gap-2"
disabled={isDownloading}
>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
@@ -161,7 +206,6 @@ export function ArtistInfo({
onClick={onDownloadSelected}
size="sm"
variant="secondary"
className="gap-2"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
@@ -172,8 +216,42 @@ export function ArtistInfo({
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
size="sm"
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
size="sm"
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} size="sm" variant="outline" className="gap-2">
<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
@@ -199,16 +277,36 @@ export function ArtistInfo({
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}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
</div>
)}
+145
View File
@@ -0,0 +1,145 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
import {
Activity,
Waves,
Radio,
TrendingUp,
FileAudio,
Clock,
Gauge
} from "lucide-react";
import type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps {
result: AnalysisResult | null;
analyzing: boolean;
onAnalyze?: () => void;
showAnalyzeButton?: boolean;
filePath?: string;
}
export function AudioAnalysis({
result,
analyzing,
onAnalyze,
showAnalyzeButton = true,
filePath
}: AudioAnalysisProps) {
if (analyzing) {
return (
<Card>
<CardContent className="px-6">
<div className="flex items-center justify-center py-8 gap-3">
<Spinner />
<span className="text-muted-foreground">Analyzing audio quality...</span>
</div>
</CardContent>
</Card>
);
}
if (!result && showAnalyzeButton) {
return (
<Card>
<CardContent className="px-6">
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Activity className="h-12 w-12 text-primary" />
<div className="text-center space-y-2">
<p className="font-medium">Audio Quality Analysis</p>
<p className="text-sm text-muted-foreground">
Verify the true lossless quality of downloaded files
</p>
</div>
{onAnalyze && (
<Button onClick={onAnalyze}>
<Activity className="h-4 w-4" />
Analyze Audio
</Button>
)}
</div>
</CardContent>
</Card>
);
}
if (!result) {
return null;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatNumber = (num: number) => {
return num.toFixed(2);
};
// Calculate Nyquist frequency (half of sample rate)
const nyquistFreq = result.sample_rate / 2;
return (
<Card>
<CardHeader>
{filePath && (
<p className="text-sm font-mono truncate">{filePath}</p>
)}
</CardHeader>
<CardContent className="space-y-2">
{/* Audio Properties - Single line */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Sample Rate:</span>
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
</div>
<div className="flex items-center gap-1">
<FileAudio className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Bit Depth:</span>
<span className="font-semibold">{result.bit_depth}</span>
</div>
<div className="flex items-center gap-1">
<Waves className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Channels:</span>
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold">{formatDuration(result.duration)}</span>
</div>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Nyquist:</span>
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
</div>
</div>
{/* Dynamic Range - Single line */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
<div className="flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Dynamic Range:</span>
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Peak:</span>
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">RMS:</span>
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
</div>
<div className="flex items-center gap-1 ml-auto">
<span className="text-muted-foreground">Samples:</span>
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,155 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps {
onBack?: () => void;
}
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
await analyzeFile(filePath);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0) return;
const filePath = paths[0];
if (!filePath.toLowerCase().endsWith(".flac")) {
toast.error("Invalid File Type", {
description: "Please drop a FLAC file for analysis",
});
return;
}
await analyzeFile(filePath);
},
[analyzeFile]
);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div>
{result && (
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
<Trash2 className="h-4 w-4" />
Clear
</Button>
)}
</div>
{/* File Selection */}
{!result && !analyzing && (
<div
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary" />
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your FLAC file here"
: "Drag and drop a FLAC file here, or click the button below to select"}
</p>
<Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" />
Select FLAC File
</Button>
</div>
)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* Detailed Analysis */}
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath} />
{/* Spectrum Visualization */}
{spectrumLoading ? (
<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
</div>
) : (
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,674 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
import {
Upload,
Download,
X,
CheckCircle2,
AlertCircle,
Trash2,
FileMusic,
WandSparkles,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
IsFFmpegInstalled,
DownloadFFmpeg,
InstallFFmpegFromFile,
ConvertAudio,
SelectAudioFiles,
} from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioFile {
path: string;
name: string;
format: string;
status: "pending" | "converting" | "success" | "error";
error?: string;
outputPath?: string;
}
const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" },
{ value: "256k", label: "256k" },
{ value: "192k", label: "192k" },
{ value: "128k", label: "128k" },
];
const STORAGE_KEY = "spotiflac_audio_converter_state";
export function AudioConverterPage() {
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
const [files, setFiles] = useState<AudioFile[]>(() => {
// Initialize from sessionStorage synchronously
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
return parsed.files;
}
}
} catch (err) {
console.error("Failed to load saved state:", err);
}
return [];
});
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
return parsed.outputFormat;
}
}
} catch (err) {
// Ignore
}
return "mp3";
});
const [bitrate, setBitrate] = useState(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.bitrate) {
return parsed.bitrate;
}
}
} catch (err) {
// Ignore
}
return "320k";
});
const [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Helper function to save state to sessionStorage
const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string }) => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (err) {
console.error("Failed to save state:", err);
}
}, []);
// Load saved state from sessionStorage on mount (only for ffmpeg check)
useEffect(() => {
checkFfmpegInstallation();
}, []);
// Save state to sessionStorage whenever files, outputFormat, or bitrate changes
useEffect(() => {
saveState({ files, outputFormat, bitrate });
}, [files, outputFormat, bitrate, saveState]);
// Auto-set output format to M4A if all files are MP3
useEffect(() => {
if (files.length === 0) return;
const allMP3 = files.every((f) => f.format === "mp3");
if (allMP3 && outputFormat !== "m4a") {
setOutputFormat("m4a");
}
}, [files, outputFormat]);
// Check if format selection should be disabled (all files are MP3)
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
// Detect fullscreen/maximized window
useEffect(() => {
const checkFullscreen = () => {
// Check if window is maximized or fullscreen
// For Wails, we can check if window height is close to screen height
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
// Also check on window focus in case user maximizes externally
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const checkFfmpegInstallation = async () => {
try {
const installed = await IsFFmpegInstalled();
setFfmpegInstalled(installed);
} catch (err) {
console.error("Failed to check ffmpeg:", err);
setFfmpegInstalled(false);
}
};
const handleInstallFfmpeg = async () => {
setInstallingFfmpeg(true);
try {
const result = await DownloadFFmpeg();
if (result.success) {
toast.success("FFmpeg Installed", {
description: "FFmpeg has been installed successfully",
});
setFfmpegInstalled(true);
} else {
toast.error("Installation Failed", {
description: result.error || "Failed to install FFmpeg",
});
}
} catch (err) {
toast.error("Installation Failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setInstallingFfmpeg(false);
}
};
const handleFFmpegFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
setIsDraggingFFmpeg(false);
if (paths.length === 0) return;
// Only process the first file
const filePath = paths[0];
const fileName = filePath.split(/[/\\]/).pop()?.toLowerCase() || "";
// Check if it's likely an ffmpeg executable
if (!fileName.includes("ffmpeg")) {
toast.error("Invalid File", {
description: "Please drop an FFmpeg executable file",
});
return;
}
setInstallingFfmpeg(true);
try {
const result = await InstallFFmpegFromFile(filePath);
if (result.success) {
toast.success("FFmpeg Installed", {
description: "FFmpeg has been installed successfully from file",
});
setFfmpegInstalled(true);
} else {
toast.error("Installation Failed", {
description: result.error || "Failed to install FFmpeg",
});
}
} catch (err) {
toast.error("Installation Failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setInstallingFfmpeg(false);
}
},
[]
);
useEffect(() => {
if (ffmpegInstalled === false) {
// Set up drag and drop for FFmpeg installation
OnFileDrop((x, y, paths) => {
handleFFmpegFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}
}, [ffmpegInstalled, handleFFmpegFileDrop]);
const handleSelectFiles = async () => {
try {
const selectedFiles = await SelectAudioFiles();
if (selectedFiles && selectedFiles.length > 0) {
addFiles(selectedFiles);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const addFiles = useCallback((paths: string[]) => {
const validExtensions = [".mp3", ".flac"];
// Check for M4A files specifically
const m4aFiles = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return ext === ".m4a";
});
if (m4aFiles.length > 0) {
toast.error("M4A files not supported", {
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
});
}
setFiles((prev) => {
const newFiles: AudioFile[] = paths
.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
})
.filter((path) => !prev.some((f) => f.path === path))
.map((path) => {
const name = path.split(/[/\\]/).pop() || path;
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
return {
path,
name,
format: ext,
status: "pending" as const,
};
});
if (newFiles.length > 0) {
if (paths.length > newFiles.length) {
const skipped = paths.length - newFiles.length;
toast.info("Some files skipped", {
description: `${skipped} file(s) were skipped (unsupported format or already added)`,
});
}
return [...prev, ...newFiles];
}
if (paths.length > 0 && m4aFiles.length === 0) {
toast.info("No new files added", {
description: "All files were already added or have unsupported format",
});
}
return prev;
});
}, []);
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0) return;
addFiles(paths);
},
[addFiles]
);
useEffect(() => {
// Only enable drag and drop for audio files if FFmpeg is installed
if (ffmpegInstalled === true) {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}
}, [handleFileDrop, ffmpegInstalled]);
const removeFile = (path: string) => {
setFiles((prev) => prev.filter((f) => f.path !== path));
};
const clearFiles = () => {
setFiles([]);
};
const handleConvert = async () => {
if (files.length === 0) {
toast.error("No files selected", {
description: "Please add audio files to convert",
});
return;
}
setConverting(true);
try {
// Include all files (including previously successful ones) for conversion
const inputPaths = files.map((f) => f.path);
// Mark all files as converting (including previously successful ones)
setFiles((prev) =>
prev.map((f) => {
if (inputPaths.includes(f.path)) {
return { ...f, status: "converting" as const, error: undefined };
}
return f;
})
);
const results = await ConvertAudio({
input_files: inputPaths,
output_format: outputFormat,
bitrate: bitrate,
});
// Update file statuses based on results
setFiles((prev) =>
prev.map((f) => {
const result = results.find((r) => r.input_file === f.path);
if (result) {
return {
...f,
status: result.success ? "success" : "error",
error: result.error,
outputPath: result.output_file,
};
}
return f;
})
);
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
if (successCount > 0) {
toast.success("Conversion Complete", {
description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
});
} else if (failCount > 0) {
toast.error("Conversion Failed", {
description: `All ${failCount} file(s) failed to convert`,
});
}
} catch (err) {
toast.error("Conversion Error", {
description: err instanceof Error ? err.message : "Unknown error",
});
setFiles((prev) =>
prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" }))
);
} finally {
setConverting(false);
}
};
const getStatusIcon = (status: AudioFile["status"]) => {
switch (status) {
case "converting":
return <Spinner className="h-4 w-4 text-primary" />;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "error":
return <AlertCircle className="h-4 w-4 text-destructive" />;
default:
return <FileMusic className="h-4 w-4 text-muted-foreground" />;
}
};
// Count files that can be converted (pending + success files that can be re-converted)
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
const successCount = files.filter((f) => f.status === "success").length;
// Show FFmpeg installation prompt if not installed
if (ffmpegInstalled === false) {
return (
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Audio Converter</h1>
</div>
<div
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
} ${
isDraggingFFmpeg
? "border-primary bg-primary/10"
: "border-muted-foreground/30"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDraggingFFmpeg(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDraggingFFmpeg(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDraggingFFmpeg(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Download className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground mb-2 text-center">
FFmpeg is required to convert audio files.
</p>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDraggingFFmpeg
? "Drop your FFmpeg executable here"
: "Drag and drop your FFmpeg executable here, or click the button below to download automatically."}
</p>
<Button
onClick={handleInstallFfmpeg}
disabled={installingFfmpeg}
size="lg"
>
{installingFfmpeg ? (
<>
<Spinner className="h-5 w-5" />
Installing FFmpeg...
</>
) : (
<>
<Download className="h-5 w-5" />
Install FFmpeg
</>
)}
</Button>
</div>
</div>
);
}
return (
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Audio Converter</h1>
{files.length > 0 && (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4" />
Add More
</Button>
<Button
variant="outline"
size="sm"
onClick={clearFiles}
disabled={converting}
>
<Trash2 className="h-4 w-4" />
Clear All
</Button>
</div>
)}
</div>
{/* Drop Zone / File List */}
<div
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
} ${
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
{files.length === 0 ? (
<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary" />
</div>
<p className="text-sm text-muted-foreground mb-2 text-center">
{isDragging
? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"}
</p>
<p className="text-xs text-muted-foreground mb-4 text-center">
Supported formats: FLAC, MP3
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5" />
Select Files
</Button>
</>
) : (
<div className="w-full h-full p-6 space-y-4 flex flex-col">
{/* Settings Row - Only show when files exist */}
<div className="space-y-2 pb-4 border-b shrink-0">
{/* Format and Bitrate in one line */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Format:</Label>
<ToggleGroup
type="single"
variant="outline"
value={outputFormat}
onValueChange={(value) => {
if (value && !isFormatDisabled) setOutputFormat(value as "mp3" | "m4a");
}}
disabled={isFormatDisabled}
>
{!isFormatDisabled && (
<ToggleGroupItem value="mp3" aria-label="MP3">
MP3
</ToggleGroupItem>
)}
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
M4A
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bitrate:</Label>
<ToggleGroup
type="single"
variant="outline"
value={bitrate}
onValueChange={(value) => {
if (value) setBitrate(value);
}}
>
{BITRATE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</div>
</div>
{/* File List Header */}
<div className="flex items-center justify-between shrink-0">
<div className="text-sm text-muted-foreground">
{files.length} file(s) {successCount} converted
</div>
</div>
{/* File List */}
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
{files.map((file) => (
<div
key={file.path}
className="flex items-center gap-3 rounded-lg border p-3"
>
{getStatusIcon(file.status)}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.error && (
<p className="truncate text-xs text-destructive">
{file.error}
</p>
)}
</div>
<span className="text-xs uppercase text-muted-foreground">
{file.format}
</span>
{file.status !== "converting" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeFile(file.path)}
disabled={converting}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
{/* Convert Button */}
<div className="flex justify-center pt-4 border-t shrink-0">
<Button
onClick={handleConvert}
disabled={converting || convertableCount === 0}
size="lg"
>
{converting ? (
<>
<Spinner className="h-4 w-4" />
Converting...
</>
) : (
<>
<WandSparkles className="h-4 w-4" />
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
</>
)}
</Button>
</div>
</div>
)}
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
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 DebugLoggerPage() {
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleCopy}
disabled={logs.length === 0}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
Copy
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleClear}
disabled={logs.length === 0}
>
<Trash2 className="h-4 w-4" />
Clear
</Button>
</div>
</div>
<div
ref={scrollRef}
className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 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>
</div>
);
}
+2 -2
View File
@@ -13,8 +13,8 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
<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}>
<StopCircle className="h-4 w-4 mr-2" />
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" />
Stop
</Button>
</div>
@@ -0,0 +1,48 @@
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DownloadProgressToastProps {
onClick: () => void;
}
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData();
// Show indicator if there are any queued or downloading items
// Don't show for completed/failed/skipped only
const hasActiveDownloads = queueInfo.queue.some(
item => item.status === "queued" || item.status === "downloading"
);
if (!hasActiveDownloads) {
return null;
}
return (
<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<Button
variant="outline"
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
onClick={onClick}
>
<div className="flex items-center gap-3">
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? '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>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
</div>
</Button>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState } from "react";
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps {
isOpen: boolean;
onClose: () => void;
}
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
total_downloaded: 0,
session_start_time: 0,
queued_count: 0,
completed_count: 0,
failed_count: 0,
skipped_count: 0,
})
);
useEffect(() => {
if (!isOpen) return;
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to get download queue:", error);
}
};
// Initial fetch
fetchQueue();
// Poll every 500ms when dialog is open
const interval = setInterval(fetchQueue, 500);
return () => clearInterval(interval);
}, [isOpen]);
const handleClearHistory = async () => {
try {
await ClearCompletedDownloads();
// Refetch immediately to update UI
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to clear history:", error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "downloading":
return <Download className="h-4 w-4 text-blue-500 animate-bounce" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "skipped":
return <FileCheck className="h-4 w-4 text-yellow-500" />;
case "queued":
return <Clock className="h-4 w-4 text-muted-foreground" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
downloading: "default",
completed: "outline",
failed: "destructive",
skipped: "secondary",
queued: "outline",
};
return (
<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>
);
};
// Format session duration
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0) return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4 pr-8">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={handleClearHistory}
>
<Trash2 className="h-3 w-3" />
Clear History
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-muted"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Queue Status */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500" />
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
{/* Session Stats */}
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader>
{/* Download Queue List */}
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>No downloads in queue</p>
</div>
) : (
queueInfo.queue.map((item) => (
<div
key={item.id}
className="border rounded-lg p-3 hover:bg-muted/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{/* Info for downloading items */}
{item.status === "downloading" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..."
: "Starting..."}
</span>
<span>
{item.speed > 0
? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
)}
{/* Completed info */}
{item.status === "completed" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>
)}
{/* Skipped info */}
{item.status === "skipped" && (
<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>
)}
{/* Error message */}
{item.status === "failed" && item.error_message && (
<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>
)}
{/* File path for completed/skipped */}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+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>
);
}
+23 -36
View File
@@ -1,18 +1,19 @@
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";
import { openExternal } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/relative-time";
interface HeaderProps {
version: string;
hasUpdate: boolean;
releaseDate?: string | null;
}
export function Header({ version, hasUpdate }: HeaderProps) {
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return (
<div className="relative">
<div className="text-center space-y-2">
@@ -30,16 +31,24 @@ export function Header({ version, hasUpdate }: HeaderProps) {
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>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" asChild>
<button
type="button"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
v{version}
</button>
</Badge>
</TooltipTrigger>
{hasUpdate && releaseDate && (
<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent>
)}
</Tooltip>
{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>
@@ -49,31 +58,9 @@ export function Header({ version, hasUpdate }: HeaderProps) {
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal/Deezer no account required.
Get Spotify tracks in true FLAC from Tidal, 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>
<p>Report bug or request feature</p>
</TooltipContent>
</Tooltip>
<Settings />
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
// Platform Icons for streaming services
export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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>
);
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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>
);
export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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>
);
+105 -11
View File
@@ -1,11 +1,12 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface PlaylistInfoProps {
playlistInfo: {
@@ -26,6 +27,8 @@ interface PlaylistInfoProps {
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
@@ -33,16 +36,39 @@ interface PlaylistInfoProps {
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
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) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => 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({
@@ -52,6 +78,8 @@ export function PlaylistInfo({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
@@ -59,16 +87,36 @@ export function PlaylistInfo({
currentDownloadInfo,
currentPage,
itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onAlbumClick,
onArtistClick,
onTrackClick,
}: PlaylistInfoProps) {
return (
<div className="space-y-6">
@@ -94,12 +142,8 @@ export function PlaylistInfo({
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
className="gap-2"
disabled={isDownloading}
>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
@@ -111,7 +155,6 @@ export function PlaylistInfo({
<Button
onClick={onDownloadSelected}
variant="secondary"
className="gap-2"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
@@ -122,8 +165,40 @@ export function PlaylistInfo({
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
@@ -153,16 +228,35 @@ export function PlaylistInfo({
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}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
</div>
</div>
+13 -4
View File
@@ -6,7 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, ArrowUpDown } from "lucide-react";
import { Search, ArrowUpDown, XCircle } from "lucide-react";
interface SearchAndSortProps {
searchQuery: string;
@@ -29,12 +29,21 @@ export function SearchAndSort({
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
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]">
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectTrigger className="w-[200px] gap-1.5">
<ArrowUpDown className="h-4 w-4" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
+73 -53
View File
@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
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";
@@ -9,66 +8,87 @@ import {
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 }: SearchBarProps) {
export function SearchBar({
url,
loading,
onUrlChange,
onFetch,
history,
onHistorySelect,
onHistoryRemove,
hasResult,
}: SearchBarProps) {
return (
<Card>
<CardContent className="px-6 py-6 space-y-4">
<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">
<Input
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"
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 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>
<p className="mt-1">Note: Playlist must be public (not private)</p>
</TooltipContent>
</Tooltip>
</div>
</CardContent>
</Card>
<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>
);
}
-363
View File
@@ -1,363 +0,0 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} 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 { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<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" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<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" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<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>
);
export function Settings() {
const [open, setOpen] = useState(false);
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [, setIsLoadingDefaults] = useState(false);
// Apply saved settings
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
// Setup listener for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [savedSettings.themeMode, savedSettings.theme]);
// Apply temp settings for preview when dialog is open
useEffect(() => {
if (open) {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
// Setup listener for system theme changes during preview
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (tempSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(tempSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}
}, [open, tempSettings.themeMode, tempSettings.theme]);
useEffect(() => {
// Load settings with defaults from backend on mount
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
setIsLoadingDefaults(true);
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
setIsLoadingDefaults(false);
}
};
loadDefaults();
}, []);
// Reset temp settings when dialog opens
useEffect(() => {
if (open) {
setTempSettings(savedSettings);
}
}, [open, savedSettings]);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
setOpen(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
// Apply default theme mode and theme
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
};
const handleCancel = () => {
// Revert to saved settings
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
setOpen(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Dialog is closing, revert to saved settings
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
}
setOpen(newOpen);
};
const handleDownloadPathChange = (value: string) => {
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
};
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz") => {
setTempSettings((prev) => ({ ...prev, downloader: value }));
};
const handleThemeChange = (value: string) => {
setTempSettings((prev) => ({ ...prev, theme: value }));
};
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
setTempSettings((prev) => ({ ...prev, themeMode: value }));
};
const handleBrowseFolder = async () => {
try {
// 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 selecting folder:", error);
alert(`Error selecting folder: ${error}`);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<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 mr-2" />
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">
<span className="flex items-center">
<TidalIcon />
<DeezerIcon />
<QobuzIcon />
Auto (Tidal Deezer Qobuz)
</span>
</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>
</SelectContent>
</Select>
</div>
{/* File Settings */}
<div className="space-y-3 pt-3 border-t">
<h3 className="font-medium text-sm">File Settings</h3>
{/* Filename Format */}
<div className="space-y-1.5">
<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">
<RadioGroupItem value="title-artist" id="title-artist" />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-xs">Title - Artist</Label>
</div>
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="artist-title" id="artist-title" />
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-xs">Artist - Title</Label>
</div>
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="title" id="title" />
<Label htmlFor="title" className="cursor-pointer font-normal text-xs">Title</Label>
</div>
</RadioGroup>
</div>
{/* Subfolder Options */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="track-number"
checked={tempSettings.trackNumber}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="artist-subfolder"
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>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
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>
</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" />
Reset to Default
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel} size="sm">
Cancel
</Button>
<Button onClick={handleSave} size="sm" className="gap-1.5">
<Save className="h-3.5 w-3.5" />
Save Changes
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+408
View File
@@ -0,0 +1,408 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
// 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 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 SettingsPage() {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [savedSettings.themeMode, savedSettings.theme]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
toast.success("Settings reset to default");
};
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
} catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Settings</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 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) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
</div>
{/* Theme Mode */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select
value={tempSettings.themeMode}
onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}
>
<SelectTrigger id="theme-mode">
<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>
{/* Accent */}
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select
value={tempSettings.theme}
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
>
<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>
{/* Font */}
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select
value={tempSettings.fontFamily}
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Sound Effects */}
<div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch
id="sfx-enabled"
checked={tempSettings.sfxEnabled}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
/>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader" className="text-sm">Source</Label>
<div className="flex gap-2">
<Select
value={tempSettings.downloader}
onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center"><TidalIcon />Tidal</span>
</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>
{/* Quality dropdown for Tidal */}
{tempSettings.downloader === "tidal" && (
<Select
value={tempSettings.tidalQuality}
onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}
>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
</SelectContent>
</Select>
)}
{/* Quality dropdown for Qobuz */}
{tempSettings.downloader === "qobuz" && (
<Select
value={tempSettings.qobuzQuality}
onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}
>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
<SelectItem value="7">FLAC 24-bit</SelectItem>
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
</SelectContent>
</Select>
)}
</div>
</div>
{/* Embed Lyrics & Embed Max Quality Cover */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch
id="embed-lyrics"
checked={tempSettings.embedLyrics}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch
id="embed-max-quality-cover"
checked={tempSettings.embedMaxQualityCover}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}
/>
</div>
</div>
<div className="border-t" />
{/* Folder Structure */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (
<InputWithContext
value={tempSettings.folderTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm"
/>
)}
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
</p>
)}
</div>
<div className="border-t" />
{/* Filename Format */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm"
/>
)}
{tempSettings.filenameTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={handleReset} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { Home, Settings, Bug, Activity, FileMusic, LayoutGrid } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const navItems = [
{ id: "main" as PageType, icon: Home, label: "Home" },
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
{ id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" },
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
];
return (
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
{navItems.map((item) => (
<Tooltip key={item.id} delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === item.id ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange(item.id)}
>
<item.icon className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
))}
</div>
{/* GitHub - below debug */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/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>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bug</p>
</TooltipContent>
</Tooltip>
{/* Other Projects at bottom */}
<div className="mt-auto">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")}
>
<LayoutGrid className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Other Projects</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
@@ -0,0 +1,289 @@
import { useEffect, useRef } from "react";
import type { SpectrumData } from "@/types/api";
interface SpectrumVisualizationProps {
sampleRate: number;
bitsPerSample: number;
duration: number;
spectrumData?: SpectrumData;
}
export function SpectrumVisualization({
sampleRate,
bitsPerSample,
duration,
spectrumData,
}: SpectrumVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Calculate margins for labels
const marginLeft = 70; // More space for Frequency label
const marginRight = 70; // Space for color bar
const marginTop = 30; // More space at top
const marginBottom = 65; // More space at bottom for Time label
const plotWidth = width - marginLeft - marginRight;
const plotHeight = height - marginTop - marginBottom;
// Black background
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
// Calculate Nyquist frequency
const nyquistFreq = sampleRate / 2;
if (spectrumData) {
drawRealSpectrum(
ctx,
marginLeft,
marginTop,
plotWidth,
plotHeight,
spectrumData
);
}
// Draw axes, labels, and color bar
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
}, [sampleRate, bitsPerSample, duration, spectrumData]);
const drawRealSpectrum = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
spectrum: SpectrumData
) => {
const timeSlices = spectrum.time_slices;
if (timeSlices.length === 0) return;
const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
// Find min/max dB values
let minDB = 0;
let maxDB = -200;
timeSlices.forEach((slice) => {
slice.magnitudes.forEach((db) => {
if (db > maxDB) maxDB = db;
if (db < minDB && db > -200) minDB = db;
});
});
// Clamp range for better visualization
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
const dbRange = maxDB - minDB;
const sliceWidth = Math.ceil(width / timeSlices.length);
for (let t = 0; t < timeSlices.length; t++) {
const slice = timeSlices[t];
const xPos = x + (t / timeSlices.length) * width;
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
const db = slice.magnitudes[f];
// Linear frequency scale
const freq = (f / freqBins) * nyquistFreq;
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
// Calculate bin height
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
const nextFreqRatio = nextFreq / nyquistFreq;
const nextYPos = y + height - (nextFreqRatio * height);
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
// Normalize intensity
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
}
};
// Vibrant color scheme like Spek - NGEJERENG!
const getSpekColor = (intensity: number): string => {
if (intensity < 0.08) {
// Black to deep blue
const t = intensity / 0.08;
return `rgb(0, 0, ${Math.floor(t * 80)})`;
} else if (intensity < 0.18) {
// Deep blue to bright blue
const t = (intensity - 0.08) / 0.10;
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
} else if (intensity < 0.28) {
// Blue to magenta/purple
const t = (intensity - 0.18) / 0.10;
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
} else if (intensity < 0.40) {
// Magenta to bright red
const t = (intensity - 0.28) / 0.12;
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
} else if (intensity < 0.52) {
// Red to orange-red
const t = (intensity - 0.40) / 0.12;
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
} else if (intensity < 0.65) {
// Orange-red to bright orange
const t = (intensity - 0.52) / 0.13;
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
} else if (intensity < 0.78) {
// Orange to yellow-orange
const t = (intensity - 0.65) / 0.13;
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
} else if (intensity < 0.90) {
// Yellow-orange to bright yellow
const t = (intensity - 0.78) / 0.12;
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
} else {
// Yellow to white (hottest)
const t = (intensity - 0.90) / 0.10;
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
}
};
const drawAxesAndLabels = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
nyquistFreq: number,
duration: number,
sampleRate: number
) => {
// Frequency labels on Y-axis
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
// Generate frequency labels based on Nyquist
const freqLabels = generateFreqLabels(nyquistFreq);
freqLabels.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
ctx.fillText(label, x - 8, yPos);
}
});
// "0" at bottom
ctx.fillText("0", x - 8, y + height);
// Time labels on X-axis
ctx.textAlign = "center";
ctx.textBaseline = "top";
const timeStep = getTimeStep(duration);
for (let t = 0; t <= duration; t += timeStep) {
const xPos = x + (t / duration) * width;
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
}
// Axis titles
ctx.fillStyle = "#FFFFFF";
ctx.font = "13px Arial";
// Y-axis title: "Frequency (Hz)"
ctx.save();
ctx.translate(12, y + height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Frequency (Hz)", 0, 0);
ctx.restore();
// X-axis title: "Time (seconds)"
ctx.textAlign = "center";
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
// Sample rate info in top right
ctx.textAlign = "right";
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
};
const generateFreqLabels = (nyquistFreq: number): number[] => {
if (nyquistFreq <= 24000) {
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
} else if (nyquistFreq <= 48000) {
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
} else if (nyquistFreq <= 96000) {
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
} else {
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
}
};
const getTimeStep = (duration: number): number => {
// Always use 30s intervals like the reference image
if (duration <= 60) return 15;
if (duration <= 120) return 30;
if (duration <= 300) return 30;
if (duration <= 600) return 60;
return 60;
};
const drawColorBar = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number
) => {
// Draw gradient color bar
for (let i = 0; i < height; i++) {
const intensity = 1 - (i / height); // Top is high, bottom is low
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(x, y + i, width, 1);
}
// Border around color bar
ctx.strokeStyle = "#666666";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
// Labels
ctx.fillStyle = "#FFFFFF";
ctx.font = "11px Arial";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText("High", x + width + 5, y + 10);
ctx.fillText("Low", x + width + 5, y + height - 10);
};
return (
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
<canvas
ref={canvasRef}
width={1200}
height={600}
className="w-full h-auto"
style={{ imageRendering: "auto" }}
/>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { X, Minus, Maximize } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() {
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
return (
<>
{/* Draggable area */}
<div
className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm"
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
onDoubleClick={handleMaximize}
/>
{/* Window control buttons - Windows style, right side */}
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
<button
onClick={handleMinimize}
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Minimize"
>
<Minus className="w-3.5 h-3.5" />
</button>
<button
onClick={handleMaximize}
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Maximize"
>
<Maximize className="w-3.5 h-3.5" />
</button>
<button
onClick={handleClose}
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</>
);
}
+141 -15
View File
@@ -1,15 +1,34 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import type { TrackMetadata } from "@/types/api";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackInfoProps {
track: TrackMetadata & { album_name: string; release_date: string };
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
onDownload: (isrc: string, name: string, artists: string) => void;
isFailed: boolean;
isSkipped: boolean;
downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean;
failedLyrics?: boolean;
skippedLyrics?: boolean;
checkingAvailability?: boolean;
availability?: TrackAvailability;
downloadingCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void;
onOpenFolder: () => void;
}
@@ -18,23 +37,74 @@ export function TrackInfo({
isDownloading,
downloadingTrack,
isDownloaded,
isFailed,
isSkipped,
downloadingLyricsTrack,
downloadedLyrics,
failedLyrics,
skippedLyrics,
checkingAvailability,
availability,
downloadingCover,
onDownload,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onOpenFolder,
}: TrackInfoProps) {
const [isHoveringCover, setIsHoveringCover] = useState(false);
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="shrink-0 relative"
onMouseEnter={() => setIsHoveringCover(true)}
onMouseLeave={() => setIsHoveringCover(false)}
>
{track.images && (
<>
<img
src={track.images}
alt={track.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
{isHoveringCover && onDownloadCover && (
<div className="absolute inset-0 bg-black/50 rounded-md flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="secondary"
className="cursor-pointer"
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name)}
disabled={downloadingCover}
>
{downloadingCover ? <Spinner /> : <ImageDown className="h-5 w-5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
</div>
)}
</>
)}
</div>
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isSkipped ? (
<FileCheck className="h-6 w-6 text-yellow-500 shrink-0" />
) : isDownloaded ? (
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
) : isFailed ? (
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
@@ -48,22 +118,78 @@ export function TrackInfo({
</div>
</div>
{track.isrc && (
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<Button
onClick={() => onDownload(track.isrc, track.name, track.artists)}
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)}
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4 mr-2" />
<Download className="h-4 w-4" />
Download
</>
)}
</Button>
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : skippedLyrics ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<FileText className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
variant="outline"
disabled={checkingAvailability}
>
{checkingAvailability ? (
<Spinner />
) : availability ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
{isDownloaded && (
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
+252 -26
View File
@@ -1,7 +1,12 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle } from "lucide-react";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Pagination,
PaginationContent,
@@ -10,7 +15,8 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import type { TrackMetadata } from "@/types/api";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackListProps {
tracks: TrackMetadata[];
@@ -18,16 +24,39 @@ interface TrackListProps {
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;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => 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({
@@ -36,16 +65,36 @@ export function TrackList({
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
currentPage,
itemsPerPage,
showCheckboxes = false,
hideAlbumColumn = false,
folderName,
isArtistDiscography = false,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onPageChange,
onAlbumClick,
onArtistClick,
onTrackClick,
}: TrackListProps) {
let filteredTracks = tracks.filter((track) => {
if (!searchQuery) return true;
@@ -161,44 +210,221 @@ export function TrackList({
)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium">{track.name}</span>
{downloadedTracks.has(track.isrc) && (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
{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) ? (
<FileCheck 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}
{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">
{track.album_name}
{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)
}
size="sm"
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4 mr-2" />
Download
</>
)}
</Button>
)}
<div className="flex items-center justify-center gap-1">
{track.isrc && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)
}
size="sm"
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : skippedTracks.has(track.isrc) ? (
<FileCheck className="h-4 w-4" />
) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4" />
) : failedTracks.has(track.isrc) ? (
<XCircle className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.isrc ? (
<p>Downloading...</p>
) : skippedTracks.has(track.isrc) ? (
<p>Already exists</p>
) : downloadedTracks.has(track.isrc) ? (
<p>Downloaded</p>
) : failedTracks.has(track.isrc) ? (
<p>Failed</p>
) : (
<p>Download Track</p>
)}
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
}
size="sm"
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : skippedLyrics?.has(track.spotify_id) ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics?.has(track.spotify_id) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<FileText className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>
)}
{track.images && onDownloadCover && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId);
}}
size="sm"
variant="outline"
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
<Spinner />
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<ImageDown className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
size="sm"
variant="outline"
disabled={checkingAvailabilityTrack === track.spotify_id}
>
{checkingAvailabilityTrack === track.spotify_id ? (
<Spinner />
) : availabilityMap?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availabilityMap?.has(track.spotify_id) ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
</div>
</td>
</tr>
))}
-66
View File
@@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+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,
}
+1 -1
View File
@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
className
)}
{...props}
@@ -0,0 +1,216 @@
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 when user explicitly opens the context menu.
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setCanPaste(text.length > 0);
} catch {
setCanPaste(false);
}
};
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={(open) => {
if (open) {
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 };
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+1
View File
@@ -36,6 +36,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
left: "calc(56px + 1rem)",
} as React.CSSProperties
}
{...props}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+172
View File
@@ -0,0 +1,172 @@
import { useState, useCallback, useEffect } from "react";
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
const STORAGE_KEY = "spotiflac_audio_analysis_state";
export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(() => {
// Load from sessionStorage on mount - only detail, no spectrum
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.filePath && parsed.result) {
// Return result WITHOUT spectrum - spectrum will be loaded async
return {
...parsed.result,
spectrum: undefined,
};
}
}
} catch (err) {
console.error("Failed to load saved analysis state:", err);
}
return null;
});
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
// Load file path from sessionStorage
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
return parsed.filePath || "";
}
} catch (err) {
// Ignore
}
return "";
});
const [error, setError] = useState<string | null>(null);
const [spectrumLoading, setSpectrumLoading] = useState(() => {
// If result exists from sessionStorage, show loading for spectrum
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.filePath && parsed.result) {
// Always show loading initially, will be resolved async
return true;
}
}
} catch (err) {
// Ignore
}
return false;
});
const analyzeFile = useCallback(async (filePath: string) => {
if (!filePath) {
setError("No file path provided");
return null;
}
setAnalyzing(true);
setError(null);
setResult(null);
setSelectedFilePath(filePath);
try {
logger.info(`Analyzing audio file: ${filePath}`);
const startTime = Date.now();
const response = await AnalyzeTrack(filePath);
const analysisResult: AnalysisResult = JSON.parse(response);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`);
// Save spectrum to memory cache
if (analysisResult.spectrum) {
setSpectrumCache(filePath, analysisResult.spectrum);
}
// Save detail (without spectrum) to sessionStorage
const { spectrum, ...detailResult } = analysisResult;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
filePath,
result: detailResult,
}));
} catch (err) {
console.error("Failed to save analysis state:", err);
}
setResult(analysisResult);
setSpectrumLoading(false); // Spectrum is now available
return analysisResult;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`);
setError(errorMessage);
toast.error("Audio Analysis Failed", {
description: errorMessage,
});
return null;
} finally {
setAnalyzing(false);
}
}, []);
const clearResult = useCallback(() => {
setResult(null);
setError(null);
setSelectedFilePath("");
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch (err) {
// Ignore
}
clearSpectrumCache();
}, []);
// Load spectrum from cache asynchronously after detail is displayed
useEffect(() => {
// Only load spectrum if we have result without spectrum and are in loading state
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
return;
}
// Load spectrum asynchronously to avoid blocking UI
// Use requestAnimationFrame to ensure detail renders first
let rafId: number;
const loadSpectrum = () => {
rafId = requestAnimationFrame(() => {
const cachedSpectrum = getSpectrumCache(selectedFilePath);
if (cachedSpectrum) {
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
setSpectrumLoading(false);
} else {
// Spectrum not in cache - user needs to re-analyze
setSpectrumLoading(false);
}
});
};
// Double RAF to ensure detail is fully rendered
requestAnimationFrame(() => {
requestAnimationFrame(loadSpectrum);
});
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [result, selectedFilePath, spectrumLoading]);
return {
analyzing,
result,
error,
selectedFilePath,
spectrumLoading,
analyzeFile,
clearResult,
};
}
+69
View File
@@ -0,0 +1,69 @@
import { useState, useCallback } from "react";
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
import type { TrackAvailability } from "@/types/api";
import { logger } from "@/lib/logger";
export function useAvailability() {
const [checking, setChecking] = useState(false);
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
const [error, setError] = useState<string | null>(null);
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
if (!spotifyId) {
setError("No Spotify ID provided");
return null;
}
// Check if already cached
if (availabilityMap.has(spotifyId)) {
return availabilityMap.get(spotifyId)!;
}
setChecking(true);
setCheckingTrackId(spotifyId);
setError(null);
try {
logger.info(`Checking availability for track: ${spotifyId}`);
const response = await CheckTrackAvailability(spotifyId, isrc || "");
const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => {
const newMap = new Map(prev);
newMap.set(spotifyId, availability);
return newMap;
});
logger.success(`Availability check completed for ${spotifyId}`);
return availability;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
logger.error(`Availability check error: ${errorMessage}`);
setError(errorMessage);
return null;
} finally {
setChecking(false);
setCheckingTrackId(null);
}
}, [availabilityMap]);
const getAvailability = useCallback((spotifyId: string) => {
return availabilityMap.get(spotifyId);
}, [availabilityMap]);
const clearAvailability = useCallback(() => {
setAvailabilityMap(new Map());
setError(null);
}, []);
return {
checking,
checkingTrackId,
availabilityMap,
error,
checkAvailability,
getAvailability,
clearAvailability,
};
}
+238
View File
@@ -0,0 +1,238 @@
import { useState, useRef } from "react";
import { downloadCover } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } 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 useCover() {
const [downloadingCover, setDownloadingCover] = useState(false);
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false);
const handleDownloadCover = async (
coverUrl: string,
trackName: string,
artistName: string,
albumName?: string,
playlistName?: string,
position?: number,
trackId?: string
) => {
if (!coverUrl) {
toast.error("No cover URL found for this track");
return;
}
const id = trackId || `${trackName}-${artistName}`;
logger.info(`downloading cover: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingCover(true);
setDownloadingCoverTrack(id);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadCover({
cover_url: coverUrl,
track_name: trackName,
artist_name: artistName,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
});
if (response.success) {
if (response.already_exists) {
toast.info("Cover file already exists");
setSkippedCovers((prev) => new Set(prev).add(id));
} else {
toast.success("Cover downloaded successfully");
setDownloadedCovers((prev) => new Set(prev).add(id));
}
setFailedCovers((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
} else {
toast.error(response.error || "Failed to download cover");
setFailedCovers((prev) => new Set(prev).add(id));
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download cover");
setFailedCovers((prev) => new Set(prev).add(id));
} finally {
setDownloadingCover(false);
setDownloadingCoverTrack(null);
}
};
const handleDownloadAllCovers = async (
tracks: TrackMetadata[],
playlistName?: string
) => {
if (tracks.length === 0) {
toast.error("No tracks to download covers");
return;
}
const settings = getSettings();
setIsBulkDownloadingCovers(true);
setCoverDownloadProgress(0);
stopBulkDownloadRef.current = false;
let completed = 0;
let success = 0;
let skipped = 0;
let failed = 0;
for (let i = 0; i < tracks.length; i++) {
if (stopBulkDownloadRef.current) {
toast.info("Cover download stopped");
break;
}
const track = tracks[i];
if (!track.images) {
completed++;
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
continue;
}
const id = track.spotify_id || `${track.name}-${track.artists}`;
setDownloadingCoverTrack(id);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: i + 1,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadCover({
cover_url: track.images,
track_name: track.name,
artist_name: track.artists,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: i + 1,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedCovers((prev) => new Set(prev).add(id));
} else {
success++;
setDownloadedCovers((prev) => new Set(prev).add(id));
}
} else {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
} catch {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
completed++;
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
}
setDownloadingCoverTrack(null);
setIsBulkDownloadingCovers(false);
setCoverDownloadProgress(0);
if (!stopBulkDownloadRef.current) {
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
}
};
const handleStopCoverDownload = () => {
stopBulkDownloadRef.current = true;
};
const resetCoverState = () => {
setDownloadedCovers(new Set());
setFailedCovers(new Set());
setSkippedCovers(new Set());
};
return {
downloadingCover,
downloadingCoverTrack,
downloadedCovers,
failedCovers,
skippedCovers,
isBulkDownloadingCovers,
coverDownloadProgress,
handleDownloadCover,
handleDownloadAllCovers,
handleStopCoverDownload,
resetCoverState,
};
}
+643 -73
View File
@@ -1,8 +1,9 @@
import { useState, useRef } from "react";
import { downloadTrack } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { getSettings, parseTemplate, type TemplateData } 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() {
@@ -11,6 +12,8 @@ export function useDownload() {
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;
@@ -19,131 +22,559 @@ export function useDownload() {
const downloadWithAutoFallback = async (
isrc: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: any,
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean
position?: number,
spotifyId?: string,
durationMs?: number,
releaseYear?: string,
albumArtist?: string,
releaseDate?: string,
coverUrl?: string,
spotifyTrackNumber?: number,
spotifyDiscNumber?: number,
spotifyTotalTracks?: number
) => {
let service = settings.downloader;
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
// Build template data for folder path
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
year: releaseYear,
playlist: playlistName?.replace(/\//g, placeholder),
isrc: isrc,
};
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
// Apply folder template if available
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
// Use album track number if template contains {album}
if (settings.folderTemplate.includes("{album}")) {
useAlbumTrackNumber = true;
}
}
if (service === "auto") {
// Try Tidal first
try {
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,
});
// Always add item to queue before downloading
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
if (tidalResponse.success) {
return tidalResponse;
if (service === "auto") {
// Get all streaming URLs once from song.link API
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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);
}
} catch (tidalErr) {
// Tidal failed, continue to Deezer
}
// Try Deezer second
try {
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,
});
// Convert duration from ms to seconds for backend
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
if (deezerResponse.success) {
return deezerResponse;
// 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,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.tidal_url,
duration: durationSeconds,
item_id: itemID, // Pass the same itemID through all attempts
audio_format: settings.tidalQuality || "LOSSLESS", // Use default LOSSLESS for auto mode
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
if (tidalResponse.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
return tidalResponse;
}
logger.warning(`tidal failed, trying amazon...`);
} catch (tidalErr) {
logger.error(`tidal error: ${tidalErr}`);
}
}
// Try Amazon second
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,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
if (amazonResponse.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return amazonResponse;
}
logger.warning(`amazon failed, trying qobuz...`);
} catch (amazonErr) {
logger.error(`amazon error: ${amazonErr}`);
}
} catch (deezerErr) {
// Deezer failed, continue to Qobuz
}
// Try Qobuz as last fallback
service = "qobuz";
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
const qobuzResponse = await downloadTrack({
isrc,
service: "qobuz",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
// If Qobuz also failed, mark the item as failed
if (!qobuzResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
}
return qobuzResponse;
}
return await downloadTrack({
// Single service download (not auto-fallback)
// Convert duration from ms to seconds for backend
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
// Determine audio format based on service
let audioFormat: string | undefined;
if (service === "tidal") {
audioFormat = settings.tidalQuality || "LOSSLESS";
} else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "deezer" | "tidal" | "qobuz",
service: service as "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationSecondsForFallback,
item_id: itemID, // Pass itemID for tracking
audio_format: audioFormat,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
// Mark as failed if download failed for single-service attempt
if (!singleServiceResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
};
const downloadWithItemID = async (
isrc: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: any,
itemID: string,
trackName?: string,
artistName?: string,
albumName?: string,
folderName?: string,
position?: number,
spotifyId?: string,
durationMs?: number,
isAlbum?: boolean,
releaseYear?: string,
albumArtist?: string,
releaseDate?: string,
coverUrl?: string,
spotifyTrackNumber?: number,
spotifyDiscNumber?: number,
spotifyTotalTracks?: number
) => {
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
year: releaseYear,
playlist: folderName?.replace(/\//g, placeholder),
isrc: isrc,
};
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (folderName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
// Apply folder template if available
if (settings.folderTemplate) {
// Parse and apply folder template
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
// Split by / (template separators), then restore placeholders as spaces
const parts = folderPath.split("/").filter(p => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
// Use album track number if template contains {album}
if (settings.folderTemplate.includes("{album}")) {
useAlbumTrackNumber = true;
}
}
if (service === "auto") {
// Get all streaming URLs once from song.link API
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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);
}
}
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
// Try Tidal first
if (streamingURLs?.tidal_url) {
try {
const tidalResponse = await downloadTrack({
isrc,
service: "tidal",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.tidal_url,
duration: durationSeconds,
item_id: itemID,
audio_format: settings.tidalQuality || "LOSSLESS", // Use default LOSSLESS for auto mode
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
if (tidalResponse.success) {
return tidalResponse;
}
} catch (tidalErr) {
console.error("Tidal error:", tidalErr);
}
}
// Try Amazon second
if (streamingURLs?.amazon_url) {
try {
const amazonResponse = await downloadTrack({
isrc,
service: "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
if (amazonResponse.success) {
return amazonResponse;
}
} catch (amazonErr) {
console.error("Amazon error:", amazonErr);
}
}
// Try Qobuz as last fallback
const qobuzResponse = await downloadTrack({
isrc,
service: "qobuz",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
// If Qobuz also failed, mark the item as failed
if (!qobuzResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
}
return qobuzResponse;
}
// Single service download
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
// Determine audio format based on service
let audioFormat: string | undefined;
if (service === "tidal") {
audioFormat = settings.tidalQuality || "LOSSLESS";
} else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
// Mark as failed if download failed for single-service attempt
if (!singleServiceResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
};
const handleDownloadTrack = async (
isrc: string,
trackName?: string,
artistName?: string,
albumName?: string
albumName?: string,
spotifyId?: string,
playlistName?: string,
durationMs?: number,
position?: number,
albumArtist?: string,
releaseDate?: string,
coverUrl?: string,
spotifyTrackNumber?: number,
spotifyDiscNumber?: number,
spotifyTotalTracks?: number
) => {
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
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = releaseDate?.substring(0, 4);
const response = await downloadWithAutoFallback(
isrc,
settings,
trackName,
artistName,
albumName,
undefined,
false
playlistName,
position, // Pass position for track numbering
spotifyId,
durationMs,
releaseYear,
albumArtist || "",
releaseDate,
coverUrl,
spotifyTrackNumber, // Spotify album track number
spotifyDiscNumber, // Spotify disc number
spotifyTotalTracks // Total tracks in album
);
if (response.success) {
toast.success(response.message);
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);
}
@@ -152,21 +583,37 @@ export function useDownload() {
const handleDownloadSelected = async (
selectedTracks: string[],
allTracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
folderName?: string,
isAlbum?: 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);
// Pre-add ALL tracks to the queue before starting downloads
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const isrc of selectedTracks) {
const track = allTracks.find((t) => t.isrc === isrc);
const itemID = await AddToDownloadQueue(
isrc,
track?.name || "",
track?.artists || "",
track?.album_name || ""
);
itemIDs.push(itemID);
}
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
const total = selectedTracks.length;
for (let i = 0; i < selectedTracks.length; i++) {
@@ -179,6 +626,7 @@ export function useDownload() {
const isrc = selectedTracks[i];
const track = allTracks.find((t) => t.isrc === isrc);
const itemID = itemIDs[i];
setDownloadingTrack(isrc);
@@ -187,24 +635,58 @@ export function useDownload() {
}
try {
const response = await downloadWithAutoFallback(
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track?.release_date?.substring(0, 4);
// Download with pre-created itemID
const response = await downloadWithItemID(
isrc,
settings,
itemID,
track?.name,
track?.artists,
track?.album_name,
playlistName,
isArtistDiscography
folderName,
i + 1, // Sequential position based on selection order
track?.spotify_id,
track?.duration_ms,
isAlbum,
releaseYear,
track?.album_artist || "", // Use album_artist from Spotify metadata
track?.release_date,
track?.images, // Spotify cover URL
track?.track_number, // Spotify album track number
track?.disc_number, // Spotify disc number
track?.total_tracks // Total tracks in album
);
if (response.success) {
successCount++;
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));
// Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -216,17 +698,34 @@ export function useDownload() {
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
if (errorCount === 0) {
// Cancel any remaining queued items
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems();
// 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 {
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
// 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
folderName?: string,
isAlbum?: boolean
) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc);
@@ -235,13 +734,28 @@ export function useDownload() {
return;
}
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
const settings = getSettings();
setIsDownloading(true);
setBulkDownloadType("all");
setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const track of tracksWithIsrc) {
const itemID = await AddToDownloadQueue(
track.isrc,
track.name,
track.artists,
track.album_name || ""
);
itemIDs.push(itemID);
}
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
const total = tracksWithIsrc.length;
for (let i = 0; i < tracksWithIsrc.length; i++) {
@@ -253,29 +767,63 @@ export function useDownload() {
}
const track = tracksWithIsrc[i];
const itemID = itemIDs[i];
setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try {
const response = await downloadWithAutoFallback(
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(
track.isrc,
settings,
itemID,
track.name,
track.artists,
track.album_name,
playlistName,
isArtistDiscography
folderName,
i + 1,
track.spotify_id,
track.duration_ms,
isAlbum,
releaseYear,
track.album_artist || "", // Use album_artist from Spotify metadata
track.release_date,
track.images, // Spotify cover URL
track.track_number, // Spotify album track number
track.disc_number, // Spotify disc number
track.total_tracks // Total tracks in album
);
if (response.success) {
successCount++;
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));
// Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -287,20 +835,40 @@ export function useDownload() {
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
if (errorCount === 0) {
// Cancel any remaining queued items
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued();
// 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 {
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
// 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 {
@@ -309,6 +877,8 @@ export function useDownload() {
downloadingTrack,
bulkDownloadType,
downloadedTracks,
failedTracks,
skippedTracks,
currentDownloadInfo,
handleDownloadTrack,
handleDownloadSelected,
+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;
}
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
export function useDownloadQueueData() {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
total_downloaded: 0,
session_start_time: 0,
queued_count: 0,
completed_count: 0,
failed_count: 0,
skipped_count: 0,
})
);
useEffect(() => {
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to get download queue:", error);
}
};
// Initial fetch
fetchQueue();
// Poll every 200ms
const interval = setInterval(fetchQueue, 200);
return () => clearInterval(interval);
}, []);
return queueInfo;
}
@@ -0,0 +1,16 @@
import { useState } from "react";
export function useDownloadQueueDialog() {
const [isOpen, setIsOpen] = useState(false);
const openQueue = () => setIsOpen(true);
const closeQueue = () => setIsOpen(false);
const toggleQueue = () => setIsOpen((prev) => !prev);
return {
isOpen,
openQueue,
closeQueue,
toggleQueue,
};
}
+244
View File
@@ -0,0 +1,244 @@
import { useState, useRef } from "react";
import { downloadLyrics } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } 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 useLyrics() {
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false);
const handleDownloadLyrics = async (
spotifyId: string,
trackName: string,
artistName: string,
albumName?: string,
playlistName?: string,
position?: number
) => {
if (!spotifyId) {
toast.error("No Spotify ID found for this track");
return;
}
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingLyricsTrack(spotifyId);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path using template system
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({
spotify_id: spotifyId,
track_name: trackName,
artist_name: artistName,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
use_album_track_number: useAlbumTrackNumber,
});
if (response.success) {
if (response.already_exists) {
toast.info("Lyrics file already exists");
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
} else {
toast.success("Lyrics downloaded successfully");
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
}
setFailedLyrics((prev) => {
const newSet = new Set(prev);
newSet.delete(spotifyId);
return newSet;
});
} else {
toast.error(response.error || "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
} finally {
setDownloadingLyricsTrack(null);
}
};
const handleDownloadAllLyrics = async (
tracks: TrackMetadata[],
playlistName?: string,
_isArtistDiscography?: boolean
) => {
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
if (tracksWithSpotifyId.length === 0) {
toast.error("No tracks with Spotify ID available for lyrics download");
return;
}
const settings = getSettings();
setIsBulkDownloadingLyrics(true);
setLyricsDownloadProgress(0);
stopBulkDownloadRef.current = false;
let completed = 0;
let success = 0;
let failed = 0;
let skipped = 0;
const total = tracksWithSpotifyId.length;
for (const track of tracksWithSpotifyId) {
if (stopBulkDownloadRef.current) {
toast.info("Lyrics download stopped by user");
break;
}
const id = track.spotify_id!;
setDownloadingLyricsTrack(id);
setLyricsDownloadProgress(Math.round((completed / total) * 100));
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: track.track_number,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({
spotify_id: id,
track_name: track.name,
artist_name: track.artists,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: track.track_number || 0,
use_album_track_number: useAlbumTrackNumber,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedLyrics((prev) => new Set(prev).add(id));
} else {
success++;
setDownloadedLyrics((prev) => new Set(prev).add(id));
}
setFailedLyrics((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
} else {
failed++;
setFailedLyrics((prev) => new Set(prev).add(id));
}
} catch (err) {
failed++;
logger.error(`error downloading lyrics: ${track.name} - ${err}`);
setFailedLyrics((prev) => new Set(prev).add(id));
}
completed++;
}
setDownloadingLyricsTrack(null);
setIsBulkDownloadingLyrics(false);
setLyricsDownloadProgress(0);
if (!stopBulkDownloadRef.current) {
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
}
};
const handleStopLyricsDownload = () => {
logger.info("lyrics download stopped by user");
stopBulkDownloadRef.current = true;
toast.info("Stopping lyrics download...");
};
const resetLyricsState = () => {
setDownloadedLyrics(new Set());
setFailedLyrics(new Set());
setSkippedLyrics(new Set());
};
return {
downloadingLyricsTrack,
downloadedLyrics,
failedLyrics,
skippedLyrics,
isBulkDownloadingLyrics,
lyricsDownloadProgress,
handleDownloadLyrics,
handleDownloadAllLyrics,
handleStopLyricsDownload,
resetLyricsState,
};
}
+92 -4
View File
@@ -1,6 +1,7 @@
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() {
@@ -15,17 +16,52 @@ export function useMetadata() {
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) {
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
setLoading(false);
}
@@ -33,6 +69,7 @@ export function useMetadata() {
const handleFetchMetadata = async (url: string) => {
if (!url.trim()) {
logger.warning("empty url provided");
toast.error("Please enter a Spotify URL");
return;
}
@@ -42,10 +79,13 @@ export function useMetadata() {
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);
@@ -56,15 +96,30 @@ export function useMetadata() {
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) {
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
setLoading(false);
}
@@ -75,23 +130,54 @@ export function useMetadata() {
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 data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
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) {
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
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);
@@ -108,9 +194,11 @@ export function useMetadata() {
showAlbumDialog,
setShowAlbumDialog,
selectedAlbum,
pendingArtistName,
handleFetchMetadata,
handleConfirmFetch,
handleAlbumClick,
handleConfirmAlbumFetch,
handleArtistClick,
};
}
+101 -40
View File
@@ -26,19 +26,6 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
@@ -61,19 +48,6 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
@@ -95,19 +69,6 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
@@ -119,10 +80,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;
@@ -160,6 +125,54 @@
@apply text-blue-600;
}
/* Ensure description text uses same color as title */
[data-sonner-toast] [data-description],
[data-sonner-toast] [data-description] * {
opacity: 1 !important;
}
/* Specific color for each toast type - match icon color */
[data-sonner-toast][data-type="success"] [data-description],
[data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 - same as icon */
}
[data-sonner-toast][data-type="error"] [data-description],
[data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 - same as icon */
}
[data-sonner-toast][data-type="warning"] [data-description],
[data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
}
[data-sonner-toast][data-type="info"] [data-description],
[data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
}
/* Dark mode - use same icon colors */
.dark [data-sonner-toast][data-type="success"] [data-description],
.dark [data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 */
}
.dark [data-sonner-toast][data-type="error"] [data-description],
.dark [data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 */
}
.dark [data-sonner-toast][data-type="warning"] [data-description],
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 */
}
.dark [data-sonner-toast][data-type="info"] [data-description],
.dark [data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 */
}
/* Dark mode toast styling */
.dark [data-sonner-toast][data-type="success"] {
@apply bg-green-950 border-green-800 text-green-100;
@@ -192,3 +205,51 @@
.dark [data-sonner-toast][data-type="info"] [data-icon] {
@apply text-blue-400;
}
/* Custom Scrollbar - mengikuti primary color theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
opacity: 0.7;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
filter: brightness(1.2);
}
/* Firefox scrollbar support */
* {
scrollbar-width: thin;
scrollbar-color: var(--primary) var(--secondary);
}
/* Custom scrollbar class untuk komponen tertentu */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--muted);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
+19 -1
View File
@@ -3,8 +3,12 @@ import type {
DownloadRequest,
DownloadResponse,
HealthResponse,
LyricsDownloadRequest,
LyricsDownloadResponse,
CoverDownloadRequest,
CoverDownloadResponse,
} from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack } from "../../wailsjs/go/main/App";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
import { main } from "../../wailsjs/go/models";
export async function fetchSpotifyMetadata(
@@ -39,3 +43,17 @@ export async function checkHealth(): Promise<HealthResponse> {
time: new Date().toISOString(),
};
}
export async function downloadLyrics(
request: LyricsDownloadRequest
): Promise<LyricsDownloadResponse> {
const req = new main.LyricsDownloadRequest(request);
return await DownloadLyrics(req);
}
export async function downloadCover(
request: CoverDownloadRequest
): Promise<CoverDownloadResponse> {
const req = new main.CoverDownloadRequest(request);
return await DownloadCover(req);
}
+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();
+59
View File
@@ -0,0 +1,59 @@
/**
* Format a date to relative time string with max 2 units
* e.g., "23 hours 32 minutes ago", "1 day 14 hours ago"
*/
export function formatRelativeTime(date: Date | string | number): string {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
if (diffMs < 0) return "just now";
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
const remainingMonths = Math.floor((days % 365) / 30);
if (remainingMonths > 0) {
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
}
} else if (months > 0) {
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
const remainingDays = days % 30;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (weeks > 0) {
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
const remainingDays = days % 7;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (days > 0) {
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
const remainingHours = hours % 24;
if (remainingHours > 0) {
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
}
} else if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
const remainingMinutes = minutes % 60;
if (remainingMinutes > 0) {
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
}
} else if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
} else {
return "just now";
}
return "Released " + parts.slice(0, 2).join(" ") + " ago";
}
+173 -9
View File
@@ -1,15 +1,77 @@
import { GetDefaults } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans";
// Folder structure presets
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom";
// Filename format presets
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom";
export interface Settings {
downloadPath: string;
downloader: "auto" | "deezer" | "tidal" | "qobuz";
downloader: "auto" | "tidal" | "qobuz" | "amazon";
theme: string;
themeMode: "auto" | "light" | "dark";
filenameFormat: "title-artist" | "artist-title" | "title";
artistSubfolder: boolean;
albumSubfolder: boolean;
fontFamily: FontFamily;
// New template system
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
filenameTemplate: string;
// Legacy settings (kept for migration)
filenameFormat?: "title-artist" | "artist-title" | "title";
artistSubfolder?: boolean;
albumSubfolder?: boolean;
trackNumber: boolean;
operatingSystem: "Windows" | "linux/MacOS"
sfxEnabled: boolean;
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
// Quality settings for specific sources
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
}
// Folder preset templates
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = {
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"custom": { label: "Custom...", template: "" },
};
// Filename preset templates
export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template: string }> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"custom": { label: "Custom...", template: "" },
};
// Available template variables
export const TEMPLATE_VARIABLES = [
{ key: "{artist}", description: "Artist name", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{year}", description: "Release year", example: "2014" },
{ key: "{isrc}", description: "ISRC code", example: "USCJY1431309" },
{ key: "{playlist}", description: "Playlist name", example: "My Playlist" },
];
// 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 = {
@@ -17,13 +79,47 @@ export const DEFAULT_SETTINGS: Settings = {
downloader: "auto",
theme: "yellow",
themeMode: "auto",
filenameFormat: "title-artist",
artistSubfolder: false,
albumSubfolder: false,
fontFamily: "google-sans",
folderPreset: "none",
folderTemplate: "",
filenamePreset: "title-artist",
filenameTemplate: "{title} - {artist}",
trackNumber: false,
operatingSystem: "Windows"
sfxEnabled: true,
embedLyrics: false,
embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalQuality: "LOSSLESS", // Default: 16-bit lossless
qobuzQuality: "6" // Default: FLAC 16-bit
};
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
];
export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) {
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.body.style.fontFamily = font.fontFamily;
}
}
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
@@ -46,6 +142,46 @@ export function getSettings(): Settings {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
// Migrate old folder/filename settings to new template system
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
} else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
} else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
} else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
// Always use detected OS (don't persist it)
parsed.operatingSystem = detectOS();
// Set default quality if not present
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
return { ...DEFAULT_SETTINGS, ...parsed };
}
} catch (error) {
@@ -54,6 +190,34 @@ export function getSettings(): Settings {
return DEFAULT_SETTINGS;
}
// Parse template and replace variables with actual values
export interface TemplateData {
artist?: string;
album?: string;
title?: string;
track?: number;
year?: string;
isrc?: string;
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template) return "";
let result = template;
// Replace each variable
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
}
export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = getSettings();
+21
View File
@@ -0,0 +1,21 @@
// Memory cache for spectrum data (fast access, cleared on page refresh)
// Key: file path, Value: spectrum data
const spectrumCache = new Map<string, any>();
export function setSpectrumCache(filePath: string, spectrumData: any): void {
spectrumCache.set(filePath, spectrumData);
}
export function getSpectrumCache(filePath: string): any | null {
return spectrumCache.get(filePath) || null;
}
export function clearSpectrumCache(filePath?: string): void {
if (filePath) {
spectrumCache.delete(filePath);
} else {
spectrumCache.clear();
}
}
+269 -449
View File
@@ -7,456 +7,276 @@ export interface Theme {
};
}
// Base colors yang sama untuk semua tema (kecuali primary dan primary-foreground)
const baseLightColors: Record<string, string> = {
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.145 0 0)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.97 0 0)",
"muted-foreground": "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
"accent-foreground": "oklch(0.205 0 0)",
destructive: "oklch(0.58 0.22 27)",
border: "oklch(0.922 0 0)",
input: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
};
const baseDarkColors: Record<string, string> = {
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
"popover-foreground": "oklch(0.985 0 0)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
"muted-foreground": "oklch(0.708 0 0)",
accent: "oklch(0.371 0 0)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
};
// Primary colors yang berbeda untuk setiap tema
interface PrimaryColors {
light: {
primary: string;
"primary-foreground": string;
};
dark: {
primary: string;
"primary-foreground": string;
};
}
const primaryColors: Record<string, PrimaryColors> = {
amber: {
light: {
primary: "oklch(0.67 0.16 58)",
"primary-foreground": "oklch(0.99 0.02 95)",
},
dark: {
primary: "oklch(0.77 0.16 70)",
"primary-foreground": "oklch(0.28 0.07 46)",
},
},
blue: {
light: {
primary: "oklch(0.488 0.243 264.376)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
},
dark: {
primary: "oklch(0.42 0.18 266)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
},
},
cyan: {
light: {
primary: "oklch(0.61 0.11 222)",
"primary-foreground": "oklch(0.98 0.02 201)",
},
dark: {
primary: "oklch(0.71 0.13 215)",
"primary-foreground": "oklch(0.30 0.05 230)",
},
},
emerald: {
light: {
primary: "oklch(0.60 0.13 163)",
"primary-foreground": "oklch(0.98 0.02 166)",
},
dark: {
primary: "oklch(0.70 0.15 162)",
"primary-foreground": "oklch(0.26 0.05 173)",
},
},
fuchsia: {
light: {
primary: "oklch(0.59 0.26 323)",
"primary-foreground": "oklch(0.98 0.02 320)",
},
dark: {
primary: "oklch(0.67 0.26 322)",
"primary-foreground": "oklch(0.98 0.02 320)",
},
},
green: {
light: {
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
},
dark: {
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
},
},
indigo: {
light: {
primary: "oklch(0.51 0.23 277)",
"primary-foreground": "oklch(0.96 0.02 272)",
},
dark: {
primary: "oklch(0.59 0.20 277)",
"primary-foreground": "oklch(0.96 0.02 272)",
},
},
lime: {
light: {
primary: "oklch(0.65 0.18 132)",
"primary-foreground": "oklch(0.99 0.03 121)",
},
dark: {
primary: "oklch(0.77 0.20 131)",
"primary-foreground": "oklch(0.27 0.07 132)",
},
},
neutral: {
light: {
primary: "oklch(0.205 0 0)",
"primary-foreground": "oklch(0.985 0 0)",
},
dark: {
primary: "oklch(0.922 0 0)",
"primary-foreground": "oklch(0.205 0 0)",
},
},
orange: {
light: {
primary: "oklch(0.646 0.222 41.116)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
},
dark: {
primary: "oklch(0.705 0.213 47.604)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
},
},
pink: {
light: {
primary: "oklch(0.59 0.22 1)",
"primary-foreground": "oklch(0.97 0.01 343)",
},
dark: {
primary: "oklch(0.66 0.21 354)",
"primary-foreground": "oklch(0.97 0.01 343)",
},
},
purple: {
light: {
primary: "oklch(0.56 0.25 302)",
"primary-foreground": "oklch(0.98 0.01 308)",
},
dark: {
primary: "oklch(0.63 0.23 304)",
"primary-foreground": "oklch(0.98 0.01 308)",
},
},
red: {
light: {
primary: "oklch(0.577 0.245 27.325)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
},
dark: {
primary: "oklch(0.637 0.237 25.331)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
},
},
rose: {
light: {
primary: "oklch(0.586 0.253 17.585)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
},
dark: {
primary: "oklch(0.645 0.246 16.439)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
},
},
sky: {
light: {
primary: "oklch(0.59 0.14 242)",
"primary-foreground": "oklch(0.98 0.01 237)",
},
dark: {
primary: "oklch(0.68 0.15 237)",
"primary-foreground": "oklch(0.29 0.06 243)",
},
},
teal: {
light: {
primary: "oklch(0.60 0.10 185)",
"primary-foreground": "oklch(0.98 0.01 181)",
},
dark: {
primary: "oklch(0.70 0.12 183)",
"primary-foreground": "oklch(0.28 0.04 193)",
},
},
violet: {
light: {
primary: "oklch(0.541 0.281 293.009)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
},
dark: {
primary: "oklch(0.606 0.25 292.717)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
},
},
yellow: {
light: {
primary: "oklch(0.852 0.199 91.936)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
},
dark: {
primary: "oklch(0.795 0.184 86.047)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
},
},
};
// Helper function untuk menggabungkan base colors dengan primary colors
function createTheme(
name: string,
label: string,
primary: PrimaryColors
): Theme {
return {
name,
label,
cssVars: {
light: { ...baseLightColors, ...primary.light },
dark: { ...baseDarkColors, ...primary.dark },
},
};
}
export const themes: Theme[] = [
{
name: "neutral",
label: "Default",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.145 0 0)",
primary: "oklch(0.205 0 0)",
"primary-foreground": "oklch(0.985 0 0)",
secondary: "oklch(0.97 0 0)",
"secondary-foreground": "oklch(0.205 0 0)",
muted: "oklch(0.97 0 0)",
"muted-foreground": "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
"accent-foreground": "oklch(0.205 0 0)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.922 0 0)",
input: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
"chart-1": "oklch(0.646 0.222 41.116)",
"chart-2": "oklch(0.6 0.118 184.704)",
"chart-3": "oklch(0.398 0.07 227.392)",
"chart-4": "oklch(0.828 0.189 84.429)",
"chart-5": "oklch(0.769 0.188 70.08)",
},
dark: {
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.922 0 0)",
"primary-foreground": "oklch(0.205 0 0)",
secondary: "oklch(0.269 0 0)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
"muted-foreground": "oklch(0.708 0 0)",
accent: "oklch(0.269 0 0)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
"chart-1": "oklch(0.488 0.243 264.376)",
"chart-2": "oklch(0.696 0.17 162.48)",
"chart-3": "oklch(0.769 0.188 70.08)",
"chart-4": "oklch(0.627 0.265 303.9)",
"chart-5": "oklch(0.645 0.246 16.439)",
},
},
},
{
name: "blue",
label: "Blue",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.488 0.243 264.376)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.708 0 0)",
"chart-1": "oklch(0.809 0.105 251.813)",
"chart-2": "oklch(0.623 0.214 259.815)",
"chart-3": "oklch(0.546 0.245 262.881)",
"chart-4": "oklch(0.488 0.243 264.376)",
"chart-5": "oklch(0.424 0.199 265.638)",
},
dark: {
background: "oklch(0.141 0.005 285.823)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.488 0.243 264.376)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
"chart-1": "oklch(0.809 0.105 251.813)",
"chart-2": "oklch(0.623 0.214 259.815)",
"chart-3": "oklch(0.546 0.245 262.881)",
"chart-4": "oklch(0.488 0.243 264.376)",
"chart-5": "oklch(0.424 0.199 265.638)",
},
},
},
{
name: "green",
label: "Green",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.841 0.238 128.85)",
"chart-1": "oklch(0.871 0.15 154.449)",
"chart-2": "oklch(0.723 0.219 149.579)",
"chart-3": "oklch(0.627 0.194 149.214)",
"chart-4": "oklch(0.527 0.154 150.069)",
"chart-5": "oklch(0.448 0.119 151.328)",
},
dark: {
background: "oklch(0.141 0.005 285.823)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.405 0.101 131.063)",
"chart-1": "oklch(0.871 0.15 154.449)",
"chart-2": "oklch(0.723 0.219 149.579)",
"chart-3": "oklch(0.627 0.194 149.214)",
"chart-4": "oklch(0.527 0.154 150.069)",
"chart-5": "oklch(0.448 0.119 151.328)",
},
},
},
{
name: "orange",
label: "Orange",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.646 0.222 41.116)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.75 0.183 55.934)",
"chart-1": "oklch(0.837 0.128 66.29)",
"chart-2": "oklch(0.705 0.213 47.604)",
"chart-3": "oklch(0.646 0.222 41.116)",
"chart-4": "oklch(0.553 0.195 38.402)",
"chart-5": "oklch(0.47 0.157 37.304)",
},
dark: {
background: "oklch(0.141 0.005 285.823)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.705 0.213 47.604)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.408 0.123 38.172)",
"chart-1": "oklch(0.837 0.128 66.29)",
"chart-2": "oklch(0.705 0.213 47.604)",
"chart-3": "oklch(0.646 0.222 41.116)",
"chart-4": "oklch(0.553 0.195 38.402)",
"chart-5": "oklch(0.47 0.157 37.304)",
},
},
},
{
name: "red",
label: "Red",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.577 0.245 27.325)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.704 0.191 22.216)",
"chart-1": "oklch(0.808 0.114 19.571)",
"chart-2": "oklch(0.637 0.237 25.331)",
"chart-3": "oklch(0.577 0.245 27.325)",
"chart-4": "oklch(0.505 0.213 27.518)",
"chart-5": "oklch(0.444 0.177 26.899)",
},
dark: {
background: "oklch(0.141 0.005 285.823)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.637 0.237 25.331)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.396 0.141 25.723)",
"chart-1": "oklch(0.808 0.114 19.571)",
"chart-2": "oklch(0.637 0.237 25.331)",
"chart-3": "oklch(0.577 0.245 27.325)",
"chart-4": "oklch(0.505 0.213 27.518)",
"chart-5": "oklch(0.444 0.177 26.899)",
},
},
},
{
name: "rose",
label: "Rose",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.586 0.253 17.585)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.712 0.194 13.428)",
"chart-1": "oklch(0.81 0.117 11.638)",
"chart-2": "oklch(0.645 0.246 16.439)",
"chart-3": "oklch(0.586 0.253 17.585)",
"chart-4": "oklch(0.514 0.222 16.935)",
"chart-5": "oklch(0.455 0.188 13.697)",
},
dark: {
background: "oklch(0.141 0.005 285.823)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.645 0.246 16.439)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.41 0.159 10.272)",
"chart-1": "oklch(0.81 0.117 11.638)",
"chart-2": "oklch(0.645 0.246 16.439)",
"chart-3": "oklch(0.586 0.253 17.585)",
"chart-4": "oklch(0.514 0.222 16.935)",
"chart-5": "oklch(0.455 0.188 13.697)",
},
},
},
{
name: "violet",
label: "Violet",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.541 0.281 293.009)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.702 0.183 293.541)",
"chart-1": "oklch(0.811 0.111 293.571)",
"chart-2": "oklch(0.606 0.25 292.717)",
"chart-3": "oklch(0.541 0.281 293.009)",
"chart-4": "oklch(0.491 0.27 292.581)",
"chart-5": "oklch(0.432 0.232 292.759)",
},
dark: {
background: "oklch(0.141 0.005 285.823)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.606 0.25 292.717)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.38 0.189 293.745)",
"chart-1": "oklch(0.811 0.111 293.571)",
"chart-2": "oklch(0.606 0.25 292.717)",
"chart-3": "oklch(0.541 0.281 293.009)",
"chart-4": "oklch(0.491 0.27 292.581)",
"chart-5": "oklch(0.432 0.232 292.759)",
},
},
},
{
name: "yellow",
label: "Yellow",
cssVars: {
light: {
background: "oklch(1 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.852 0.199 91.936)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.852 0.199 91.936)",
"chart-1": "oklch(0.905 0.182 98.111)",
"chart-2": "oklch(0.795 0.184 86.047)",
"chart-3": "oklch(0.681 0.162 75.834)",
"chart-4": "oklch(0.554 0.135 66.442)",
"chart-5": "oklch(0.476 0.114 61.907)",
},
dark: {
background: "oklch(0.141 0.005 285.823)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.795 0.184 86.047)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.421 0.095 57.708)",
"chart-1": "oklch(0.905 0.182 98.111)",
"chart-2": "oklch(0.795 0.184 86.047)",
"chart-3": "oklch(0.681 0.162 75.834)",
"chart-4": "oklch(0.554 0.135 66.442)",
"chart-5": "oklch(0.476 0.114 61.907)",
},
},
},
];
createTheme("amber", "Amber", primaryColors.amber),
createTheme("blue", "Blue", primaryColors.blue),
createTheme("cyan", "Cyan", primaryColors.cyan),
createTheme("emerald", "Emerald", primaryColors.emerald),
createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia),
createTheme("green", "Green", primaryColors.green),
createTheme("indigo", "Indigo", primaryColors.indigo),
createTheme("lime", "Lime", primaryColors.lime),
createTheme("neutral", "Neutral", primaryColors.neutral),
createTheme("orange", "Orange", primaryColors.orange),
createTheme("pink", "Pink", primaryColors.pink),
createTheme("purple", "Purple", primaryColors.purple),
createTheme("red", "Red", primaryColors.red),
createTheme("rose", "Rose", primaryColors.rose),
createTheme("sky", "Sky", primaryColors.sky),
createTheme("teal", "Teal", primaryColors.teal),
createTheme("violet", "Violet", primaryColors.violet),
createTheme("yellow", "Yellow", primaryColors.yellow),
].sort((a, b) => a.name.localeCompare(b.name));
export function applyTheme(themeName: string) {
const theme = themes.find((t) => t.name === themeName) || themes[0];
+40 -16
View File
@@ -1,31 +1,55 @@
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";
import { getSettings } from "./settings";
const toastStyle = {
className: "font-mono lowercase",
};
// Helper to check if SFX is enabled
const isSfxEnabled = () => getSettings().sfxEnabled;
// Wrapper functions for toast with sound effects
export const toastWithSound = {
success: (message: string, data?: any) => {
playSuccessSound();
return toast.success(message, data);
const msg = message.toLowerCase();
logger.success(msg);
if (isSfxEnabled()) playSuccessSound();
return toast.success(msg, { ...toastStyle, ...data });
},
error: (message: string, data?: any) => {
playErrorSound();
return toast.error(message, data);
const msg = message.toLowerCase();
logger.error(msg);
if (isSfxEnabled()) playErrorSound();
return toast.error(msg, { ...toastStyle, ...data });
},
warning: (message: string, data?: any) => {
playWarningSound();
return toast.warning(message, data);
const msg = message.toLowerCase();
logger.warning(msg);
if (isSfxEnabled()) playWarningSound();
return toast.warning(msg, { ...toastStyle, ...data });
},
info: (message: string, data?: any) => {
playInfoSound();
return toast.info(message, data);
const msg = message.toLowerCase();
logger.info(msg);
if (isSfxEnabled()) playInfoSound();
return toast.info(msg, { ...toastStyle, ...data });
},
// Default toast without specific type
message: (message: string, data?: any) => {
playInfoSound();
return toast(message, data);
const msg = message.toLowerCase();
logger.info(msg);
if (isSfxEnabled()) playInfoSound();
return toast(msg, { ...toastStyle, ...data });
},
};
+27 -3
View File
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"
import type { Settings } from "./settings";
export function cn(...inputs: ClassValue[]) {
@@ -19,10 +20,22 @@ export function sanitizePath(input: string, os: string): string {
export function joinPath(os: string, ...parts: string[]): string {
const sep = os === "Windows" ? "\\" : "/";
return parts
.filter(Boolean)
.map(p => p.replace(/^[/\\]+|[/\\]+$/g, ""))
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) {
@@ -32,4 +45,15 @@ export function buildOutputPath(settings: Settings, folder?: string) {
const sanitized = folder ? sanitizePath(folder, os) : undefined;
return sanitized ? joinPath(os, base, sanitized) : base;
}
export function openExternal(url: string) {
if (!url) return;
try {
BrowserOpenURL(url);
} catch (error) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}
}
+9 -9
View File
@@ -1,12 +1,12 @@
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";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Toaster />
</StrictMode>,
)
<Toaster position="bottom-left" duration={1000} />
</StrictMode>
);
+108 -1
View File
@@ -1,14 +1,29 @@
export interface ArtistSimple {
id: string;
name: string;
external_urls: string;
}
export interface TrackMetadata {
artists: string;
name: string;
album_name: string;
album_artist?: string;
duration_ms: number;
images: string;
release_date: string;
track_number: number;
total_tracks?: number; // Total tracks in album
disc_number?: number;
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,17 +112,31 @@ export type SpotifyMetadataResponse =
export interface DownloadRequest {
isrc: string;
service: "deezer" | "tidal" | "qobuz";
service: "tidal" | "qobuz" | "amazon";
query?: string;
track_name?: string;
artist_name?: string;
album_name?: string;
album_artist?: string;
release_date?: string;
cover_url?: string; // Spotify cover URL for embedding
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;
embed_lyrics?: boolean; // Whether to embed lyrics into the audio file
embed_max_quality_cover?: boolean; // Whether to embed max quality cover art
service_url?: string;
duration?: number; // Track duration in seconds for better matching
item_id?: string; // Optional queue item ID for multi-service fallback tracking
spotify_track_number?: number; // Track number from Spotify album
spotify_disc_number?: number; // Disc number from Spotify album
spotify_total_tracks?: number; // Total tracks in album from Spotify
}
export interface DownloadResponse {
@@ -115,9 +144,87 @@ export interface DownloadResponse {
message: string;
file?: string;
error?: string;
already_exists?: boolean;
item_id?: string; // Queue item ID for tracking
}
export interface HealthResponse {
status: string;
time: string;
}
export interface TimeSlice {
time: number;
magnitudes: number[];
}
export interface SpectrumData {
time_slices: TimeSlice[];
sample_rate: number;
freq_bins: number;
duration: number;
max_freq: number;
}
export interface AnalysisResult {
file_path: string;
sample_rate: number;
channels: number;
bits_per_sample: number;
total_samples: number;
duration: number;
bit_depth: string;
dynamic_range: number;
peak_amplitude: number;
rms_level: number;
spectrum?: SpectrumData;
}
export interface LyricsDownloadRequest {
spotify_id: string;
track_name: string;
artist_name: string;
output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
use_album_track_number?: boolean;
}
export interface LyricsDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
export interface TrackAvailability {
spotify_id: string;
tidal: boolean;
amazon: boolean;
qobuz: boolean;
tidal_url?: string;
amazon_url?: string;
qobuz_url?: string;
}
export interface CoverDownloadRequest {
cover_url: string;
track_name: string;
artist_name: string;
output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
}
export interface CoverDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
+16 -10
View File
@@ -3,38 +3,44 @@ module spotiflac
go 1.25.4
require (
github.com/bogem/id3v2/v2 v2.1.4
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/mewkiz/flac v1.0.13
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0
)
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/icza/bitio v1.1.0 // 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/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // 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
)
+55 -22
View File
@@ -1,5 +1,7 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
@@ -10,16 +12,20 @@ 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/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
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,11 +41,16 @@ 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/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -49,39 +60,61 @@ 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=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
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=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+16 -6
View File
@@ -19,21 +19,31 @@ 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,
},
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
OnStartup: app.startup,
DragAndDrop: &options.DragAndDrop{
EnableFileDrop: true,
DisableWebViewDrop: false,
CSSDropProperty: "--wails-drop-target",
CSSDropValue: "drop",
},
Bind: []interface{}{
app,
},
Windows: &windows.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,
DisableWindowIcon: false,
WebviewIsTransparent: false,
WindowIsTranslucent: false,
DisableWindowIcon: false,
DisableFramelessWindowDecorations: false,
},
})
+5 -3
View File
@@ -2,9 +2,11 @@
"vogel.qqdl.site",
"maus.qqdl.site",
"hund.qqdl.site",
"eu-maus.qqdl.site",
"eu-katze.qqdl.site",
"katze.qqdl.site",
"wolf.qqdl.site",
"tidal.kinoplus.online"
"tidal.kinoplus.online",
"tidal-api.binimum.org",
"tidal-api-2.binimum.org",
"dev-api.squid.wtf",
"triton.squid.wtf"
]
-3
View File
@@ -1,3 +0,0 @@
{
"version": "5.6"
}
+5 -8
View File
@@ -2,22 +2,19 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "SpotiFLAC",
"outputfilename": "SpotiFLAC",
"frontend:install": "pnpm install && pnpm run generate-icon",
"frontend:install": "pnpm install",
"frontend:dev:install": "pnpm install",
"frontend:build": "pnpm run build",
"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.7",
"copyright": "Copyright © 2025",
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
"productVersion": "6.8"
},
"wailsjsdir": "./frontend",
"assetdir": "./frontend/dist",
"reloaddirs": "./frontend/src"
}
}