Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b85ed89af3 | |||
| 8e78a882a3 | |||
| 237ee777c3 | |||
| b44a9abdd6 | |||
| 6c3fb13b25 | |||
| 9398e496e8 | |||
| a8d6276d00 | |||
| 4d3aac4990 | |||
| 64d7f82e52 | |||
| 910420634c | |||
| 22742f1ddd | |||
| 5c1d6619b5 | |||
| 76669f551e | |||
| ffd4daf031 | |||
| 64b86b65a1 | |||
| 8f10094e40 | |||
| 2fb544d1f8 | |||
| d16eaa324a | |||
| cc3f7640c6 | |||
| 2653586eea | |||
| 0c92385c56 | |||
| 957fb83dbc | |||
| 90f1871488 | |||
| 2d0e5055f8 | |||
| 08d02be4f2 | |||
| 5b542dcf29 | |||
| 48f9584027 | |||
| 4241a591aa | |||
| f346fbb6ba | |||
| 2172981110 | |||
| ddf1844237 | |||
| 3a5aea4c91 | |||
| 6b4ad16882 | |||
| 4dbd88e689 | |||
| d0665fdcc5 | |||
| 6ee3c2f653 | |||
| 73d8205f6f | |||
| 17fe37fbb7 | |||
| afe55db107 | |||
| 8a553774c6 | |||
| 869bf50330 | |||
| 1c03aaa92f | |||
| 1423a32528 | |||
| 633812faab |
+40
-22
@@ -2,18 +2,13 @@ name: Build Multi-Platform
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: '1.25.4'
|
GO_VERSION: '1.25.4'
|
||||||
NODE_VERSION: '20'
|
NODE_VERSION: '24'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -72,19 +67,27 @@ jobs:
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate-icon
|
pnpm run generate-icon
|
||||||
|
|
||||||
|
- name: Install UPX
|
||||||
|
run: |
|
||||||
|
choco install upx -y
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: wails build -platform windows/amd64
|
run: wails build -platform windows/amd64
|
||||||
|
|
||||||
|
- name: Compress with UPX
|
||||||
|
run: |
|
||||||
|
upx --best --lzma "build\bin\SpotiFLAC.exe"
|
||||||
|
|
||||||
- name: Prepare artifacts
|
- name: Prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-portable
|
name: windows-portable
|
||||||
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.exe
|
path: dist/SpotiFLAC.exe
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -159,16 +162,16 @@ jobs:
|
|||||||
--icon "SpotiFLAC.app" 175 120 \
|
--icon "SpotiFLAC.app" 175 120 \
|
||||||
--hide-extension "SpotiFLAC.app" \
|
--hide-extension "SpotiFLAC.app" \
|
||||||
--app-drop-link 425 120 \
|
--app-drop-link 425 120 \
|
||||||
"dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg" \
|
"dist/SpotiFLAC.dmg" \
|
||||||
"build/bin/SpotiFLAC.app" || \
|
"build/bin/SpotiFLAC.app" || \
|
||||||
# Fallback to hdiutil if create-dmg fails
|
# 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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-portable
|
name: macos-portable
|
||||||
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg
|
path: dist/SpotiFLAC.dmg
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
@@ -219,7 +222,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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)
|
# 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
|
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
|
||||||
@@ -236,10 +239,25 @@ jobs:
|
|||||||
- name: Build application
|
- name: Build application
|
||||||
run: wails build -platform linux/amd64
|
run: wails build -platform linux/amd64
|
||||||
|
|
||||||
- name: Download appimagetool
|
- name: Compress with UPX
|
||||||
run: |
|
run: |
|
||||||
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
upx --best --lzma build/bin/SpotiFLAC
|
||||||
chmod +x appimagetool
|
|
||||||
|
- 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
|
- name: Create AppImage
|
||||||
run: |
|
run: |
|
||||||
@@ -291,13 +309,13 @@ jobs:
|
|||||||
|
|
||||||
# Create AppImage
|
# Create AppImage
|
||||||
mkdir -p dist
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: linux-portable
|
name: linux-portable
|
||||||
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
|
path: dist/SpotiFLAC.AppImage
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
@@ -336,9 +354,9 @@ jobs:
|
|||||||
|
|
||||||
## Downloads
|
## Downloads
|
||||||
|
|
||||||
- `SpotiFLAC-${{ steps.version.outputs.version }}.exe` - Windows
|
- `SpotiFLAC.exe` - Windows
|
||||||
- `SpotiFLAC-${{ steps.version.outputs.version }}.dmg` - macOS
|
- `SpotiFLAC.dmg` - macOS
|
||||||
- `SpotiFLAC-${{ steps.version.outputs.version }}.AppImage` - Linux
|
- `SpotiFLAC.AppImage` - Linux
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Linux Requirements</b></summary>
|
<summary><b>Linux Requirements</b></summary>
|
||||||
@@ -362,8 +380,8 @@ jobs:
|
|||||||
|
|
||||||
After installing the dependency, make the AppImage executable:
|
After installing the dependency, make the AppImage executable:
|
||||||
```bash
|
```bash
|
||||||
chmod +x SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
|
chmod +x SpotiFLAC.AppImage
|
||||||
./SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
|
./SpotiFLAC.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ temp/
|
|||||||
*.bak
|
*.bak
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test
|
||||||
|
|
||||||
# Build notes (optional - uncomment if you don't want to commit)
|
# Build notes (optional - uncomment if you don't want to commit)
|
||||||
# BUILD_NOTES.md
|
# BUILD_NOTES.md
|
||||||
build.txt
|
build.txt
|
||||||
@@ -4,11 +4,9 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Deezer & Qobuz — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||
<br><br>
|

|
||||||
|
|
||||||

|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
@@ -18,12 +16,22 @@ Get Spotify tracks in true FLAC from Tidal, Deezer & Qobuz — no account requir
|
|||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### [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
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"spotiflac/backend"
|
"spotiflac/backend"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,26 +37,62 @@ type SpotifyMetadataRequest struct {
|
|||||||
|
|
||||||
// DownloadRequest represents the request structure for downloading tracks
|
// DownloadRequest represents the request structure for downloading tracks
|
||||||
type DownloadRequest struct {
|
type DownloadRequest struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
TrackName string `json:"track_name,omitempty"`
|
TrackName string `json:"track_name,omitempty"`
|
||||||
ArtistName string `json:"artist_name,omitempty"`
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
AlbumName string `json:"album_name,omitempty"`
|
AlbumName string `json:"album_name,omitempty"`
|
||||||
ApiURL string `json:"api_url,omitempty"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
OutputDir string `json:"output_dir,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
AudioFormat string `json:"audio_format,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"` // Spotify cover URL for embedding
|
||||||
FilenameFormat string `json:"filename_format,omitempty"`
|
ApiURL string `json:"api_url,omitempty"`
|
||||||
TrackNumber bool `json:"track_number,omitempty"`
|
OutputDir string `json:"output_dir,omitempty"`
|
||||||
Position int `json:"position,omitempty"` // Position in playlist/album (1-based)
|
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
|
// DownloadResponse represents the response structure for download operations
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
File string `json:"file,omitempty"`
|
File string `json:"file,omitempty"`
|
||||||
Error string `json:"error,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
|
// GetSpotifyMetadata fetches metadata from Spotify
|
||||||
@@ -95,11 +134,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Service == "" {
|
if req.Service == "" {
|
||||||
req.Service = "deezer"
|
req.Service = "tidal"
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.OutputDir == "" {
|
if req.OutputDir == "" {
|
||||||
req.OutputDir = "."
|
req.OutputDir = "."
|
||||||
|
} else {
|
||||||
|
// Sanitize output directory path to remove invalid characters
|
||||||
|
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.AudioFormat == "" {
|
if req.AudioFormat == "" {
|
||||||
@@ -114,48 +156,226 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
req.FilenameFormat = "title-artist"
|
req.FilenameFormat = "title-artist"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set downloading state
|
// 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.SetDownloading(true)
|
||||||
|
backend.StartDownloadItem(itemID)
|
||||||
defer backend.SetDownloading(false)
|
defer backend.SetDownloading(false)
|
||||||
|
|
||||||
if req.Service == "tidal" {
|
// Early check: Check if file with same ISRC already exists
|
||||||
searchQuery := req.Query
|
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
|
||||||
if searchQuery == "" {
|
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
|
||||||
searchQuery = req.ISRC
|
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" {
|
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||||
downloader := backend.NewTidalDownloader("")
|
downloader := backend.NewTidalDownloader("")
|
||||||
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, 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 {
|
} else {
|
||||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||||
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, 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()
|
downloader := backend.NewQobuzDownloader()
|
||||||
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName)
|
// Default to "6" (FLAC 16-bit) for Qobuz if not specified
|
||||||
if err == nil {
|
quality := req.AudioFormat
|
||||||
filename = "Downloaded via Qobuz"
|
if quality == "" {
|
||||||
}
|
quality = "6"
|
||||||
} else {
|
|
||||||
downloader := backend.NewDeezerDownloader()
|
|
||||||
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName)
|
|
||||||
if err == nil {
|
|
||||||
filename = "Downloaded via Deezer"
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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{
|
return DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: fmt.Sprintf("Download failed: %v", err),
|
Error: fmt.Sprintf("Download failed: %v", err),
|
||||||
|
ItemID: itemID,
|
||||||
}, err
|
}, 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{
|
return DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download completed successfully",
|
Message: message,
|
||||||
File: filename,
|
File: filename,
|
||||||
|
AlreadyExists: alreadyExists,
|
||||||
|
ItemID: itemID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +398,11 @@ func (a *App) SelectFolder(defaultPath string) (string, error) {
|
|||||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
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
|
// GetDefaults returns the default configuration
|
||||||
func (a *App) GetDefaults() map[string]string {
|
func (a *App) GetDefaults() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
@@ -190,8 +415,269 @@ func (a *App) GetDownloadProgress() backend.ProgressInfo {
|
|||||||
return backend.GetDownloadProgress()
|
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
|
// Quit closes the application
|
||||||
func (a *App) Quit() {
|
func (a *App) Quit() {
|
||||||
// You can add cleanup logic here if needed
|
// You can add cleanup logic here if needed
|
||||||
panic("quit") // This will trigger Wails to close the app
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,289 +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()
|
|
||||||
|
|
||||||
fmt.Println("Downloading...")
|
|
||||||
// Use progress writer to track download
|
|
||||||
pw := NewProgressWriter(out)
|
|
||||||
_, err = io.Copy(pw, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print final size
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (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, position int) 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
|
|
||||||
// Only use track number for bulk downloads (when position > 0)
|
|
||||||
if includeTrackNumber && position > 0 {
|
|
||||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filename + ".flac"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, position int, 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, position)
|
|
||||||
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...")
|
|
||||||
// Only use track number for bulk downloads (when position > 0)
|
|
||||||
trackNumberToEmbed := 0
|
|
||||||
if position > 0 {
|
|
||||||
trackNumberToEmbed = position
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: trackTitle,
|
|
||||||
Artist: artists,
|
|
||||||
Album: albumTitle,
|
|
||||||
Date: track.ReleaseDate,
|
|
||||||
TrackNumber: trackNumberToEmbed,
|
|
||||||
DiscNumber: track.DiskNumber,
|
|
||||||
ISRC: track.ISRC,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to embed metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Metadata embedded successfully!")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -48,3 +48,31 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
|||||||
|
|
||||||
return selectedPath, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, ¢iseconds)
|
||||||
|
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
@@ -3,8 +3,12 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
pathfilepath "path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
id3v2 "github.com/bogem/id3v2/v2"
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac"
|
||||||
@@ -14,10 +18,15 @@ type Metadata struct {
|
|||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album 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
|
TrackNumber int
|
||||||
|
TotalTracks int // Total tracks in album
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
Lyrics string
|
||||||
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
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 != "" {
|
if metadata.Album != "" {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
|
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
|
||||||
}
|
}
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
|
||||||
|
}
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
||||||
}
|
}
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
|
if metadata.TotalTracks > 0 {
|
||||||
|
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
|
||||||
|
}
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_ISRC, 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()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx < 0 {
|
if cmtIdx < 0 {
|
||||||
@@ -111,3 +133,470 @@ func fileExists(path string) bool {
|
|||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+325
-1
@@ -7,6 +7,34 @@ import (
|
|||||||
"time"
|
"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
|
// Global progress tracker
|
||||||
var (
|
var (
|
||||||
currentProgress float64
|
currentProgress float64
|
||||||
@@ -15,6 +43,16 @@ var (
|
|||||||
downloadingLock sync.RWMutex
|
downloadingLock sync.RWMutex
|
||||||
currentSpeed float64
|
currentSpeed float64
|
||||||
speedLock sync.RWMutex
|
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
|
// ProgressInfo represents download progress information
|
||||||
@@ -24,6 +62,19 @@ type ProgressInfo struct {
|
|||||||
SpeedMBps float64 `json:"speed_mbps"`
|
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
|
// GetDownloadProgress returns current download progress
|
||||||
func GetDownloadProgress() ProgressInfo {
|
func GetDownloadProgress() ProgressInfo {
|
||||||
downloadingLock.RLock()
|
downloadingLock.RLock()
|
||||||
@@ -80,6 +131,7 @@ type ProgressWriter struct {
|
|||||||
startTime int64
|
startTime int64
|
||||||
lastTime int64
|
lastTime int64
|
||||||
lastBytes int64
|
lastBytes int64
|
||||||
|
itemID string // Track which download item this belongs to
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||||
@@ -91,9 +143,17 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
|||||||
startTime: now,
|
startTime: now,
|
||||||
lastTime: now,
|
lastTime: now,
|
||||||
lastBytes: 0,
|
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 {
|
func getCurrentTimeMillis() int64 {
|
||||||
return time.Now().UnixMilli()
|
return time.Now().UnixMilli()
|
||||||
}
|
}
|
||||||
@@ -111,8 +171,9 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|||||||
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
|
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
|
||||||
bytesDiff := float64(pw.total - pw.lastBytes)
|
bytesDiff := float64(pw.total - pw.lastBytes)
|
||||||
|
|
||||||
|
var speedMBps float64
|
||||||
if timeDiff > 0 {
|
if timeDiff > 0 {
|
||||||
speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff
|
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||||
SetDownloadSpeed(speedMBps)
|
SetDownloadSpeed(speedMBps)
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
|
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
|
||||||
} else {
|
} else {
|
||||||
@@ -122,6 +183,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|||||||
// Update global progress
|
// Update global progress
|
||||||
SetDownloadProgress(mbDownloaded)
|
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.lastPrinted = pw.total
|
||||||
pw.lastTime = now
|
pw.lastTime = now
|
||||||
pw.lastBytes = pw.total
|
pw.lastBytes = pw.total
|
||||||
@@ -133,3 +199,261 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|||||||
func (pw *ProgressWriter) GetTotal() int64 {
|
func (pw *ProgressWriter) GetTotal() int64 {
|
||||||
return pw.total
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+123
-65
@@ -8,6 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,8 +93,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var searchResp QobuzSearchResponse
|
var searchResp QobuzSearchResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
// Read body first to handle encoding issues
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
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 {
|
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) {
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
// Map quality to Qobuz quality code
|
// Map quality to Qobuz quality code
|
||||||
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
|
// 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
|
// Decode base64 API URLs
|
||||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||||
|
|
||||||
// Try primary API first
|
// Try primary API first
|
||||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
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)
|
resp, err := q.client.Get(primaryURL)
|
||||||
if err == nil && resp.StatusCode == 200 {
|
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)
|
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))
|
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
var streamResp QobuzStreamResponse
|
||||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
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 == "" {
|
if streamResp.URL == "" {
|
||||||
@@ -167,7 +202,13 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||||
fmt.Println("Starting file download...")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -222,66 +263,70 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int) string {
|
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||||
var filename string
|
var filename string
|
||||||
|
|
||||||
// Build base filename based on format
|
// Determine track number to use
|
||||||
switch format {
|
numberToUse := position
|
||||||
case "artist-title":
|
if useAlbumTrackNumber && trackNumber > 0 {
|
||||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
numberToUse = trackNumber
|
||||||
case "title":
|
|
||||||
filename = title
|
|
||||||
default: // "title-artist"
|
|
||||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add track number prefix if enabled
|
// Check if format is a template (contains {})
|
||||||
// Only use track number for bulk downloads (when position > 0)
|
if strings.Contains(format, "{") {
|
||||||
if includeTrackNumber && position > 0 {
|
filename = format
|
||||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
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"
|
return filename + ".flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, 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)
|
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||||
|
|
||||||
// Create output directory if it doesn't exist
|
// Create output directory if it doesn't exist
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
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)
|
track, err := q.SearchByISRC(isrc)
|
||||||
if err != nil {
|
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
|
artists := spotifyArtistName
|
||||||
trackTitle := spotifyTrackName
|
trackTitle := spotifyTrackName
|
||||||
albumTitle := spotifyAlbumName
|
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("Found track: %s - %s\n", artists, trackTitle)
|
||||||
fmt.Printf("Album: %s\n", albumTitle)
|
fmt.Printf("Album: %s\n", albumTitle)
|
||||||
|
|
||||||
@@ -294,11 +339,11 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
|||||||
fmt.Println("Getting download URL...")
|
fmt.Println("Getting download URL...")
|
||||||
downloadURL, err := q.GetDownloadURL(track.ID, quality)
|
downloadURL, err := q.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
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 == "" {
|
if downloadURL == "" {
|
||||||
return fmt.Errorf("received empty download URL")
|
return "", fmt.Errorf("received empty download URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show partial URL for security
|
// Show partial URL for security
|
||||||
@@ -311,55 +356,68 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
|||||||
safeArtist := sanitizeFilename(artists)
|
safeArtist := sanitizeFilename(artists)
|
||||||
safeTitle := sanitizeFilename(trackTitle)
|
safeTitle := sanitizeFilename(trackTitle)
|
||||||
|
|
||||||
// Build filename based on format settings
|
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||||
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position)
|
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)
|
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)
|
fmt.Printf("Downloading FLAC file to: %s\n", filepath)
|
||||||
if err := q.DownloadFile(downloadURL, filepath); err != nil {
|
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)
|
fmt.Printf("Downloaded: %s\n", filepath)
|
||||||
|
|
||||||
coverPath := ""
|
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"
|
coverPath = filepath + ".cover.jpg"
|
||||||
fmt.Println("Downloading cover art...")
|
coverClient := NewCoverClient()
|
||||||
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
|
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||||
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||||
|
coverPath = ""
|
||||||
} else {
|
} else {
|
||||||
defer os.Remove(coverPath)
|
defer os.Remove(coverPath)
|
||||||
|
fmt.Println("Spotify cover downloaded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Embedding metadata and cover art...")
|
fmt.Println("Embedding metadata and cover art...")
|
||||||
|
|
||||||
releaseYear := ""
|
// Determine track number to embed - ALL from Spotify
|
||||||
if len(track.ReleaseDateOriginal) >= 4 {
|
trackNumberToEmbed := spotifyTrackNumber
|
||||||
releaseYear = track.ReleaseDateOriginal[:4]
|
if position > 0 && !useAlbumTrackNumber {
|
||||||
}
|
trackNumberToEmbed = position // Use playlist position
|
||||||
|
|
||||||
// Only use track number for bulk downloads (when position > 0)
|
|
||||||
trackNumberToEmbed := 0
|
|
||||||
if position > 0 {
|
|
||||||
trackNumberToEmbed = position
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALL metadata from Spotify
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: trackTitle,
|
Title: trackTitle,
|
||||||
Artist: artists,
|
Artist: artists,
|
||||||
Album: albumTitle,
|
Album: albumTitle,
|
||||||
Date: releaseYear,
|
AlbumArtist: spotifyAlbumArtist,
|
||||||
|
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||||
TrackNumber: trackNumberToEmbed,
|
TrackNumber: trackNumberToEmbed,
|
||||||
DiscNumber: track.MediaNumber,
|
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||||
ISRC: track.ISRC,
|
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 {
|
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!")
|
fmt.Println("Metadata embedded successfully!")
|
||||||
return nil
|
return filepath, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -29,7 +29,7 @@ const (
|
|||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
|
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 (
|
var (
|
||||||
@@ -57,29 +57,49 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|||||||
|
|
||||||
// TrackMetadata mirrors the filtered track payload returned by the Python script.
|
// TrackMetadata mirrors the filtered track payload returned by the Python script.
|
||||||
type TrackMetadata struct {
|
type TrackMetadata struct {
|
||||||
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AlbumName string `json:"album_name"`
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
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.
|
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
||||||
type AlbumTrackMetadata struct {
|
type AlbumTrackMetadata struct {
|
||||||
Artists string `json:"artists"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Artists string `json:"artists"`
|
||||||
AlbumName string `json:"album_name"`
|
Name string `json:"name"`
|
||||||
DurationMS int `json:"duration_ms"`
|
AlbumName string `json:"album_name"`
|
||||||
Images string `json:"images"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
ReleaseDate string `json:"release_date"`
|
DurationMS int `json:"duration_ms"`
|
||||||
TrackNumber int `json:"track_number"`
|
Images string `json:"images"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ISRC string `json:"isrc"`
|
TrackNumber int `json:"track_number"`
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
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 {
|
type TrackResponse struct {
|
||||||
@@ -93,6 +113,8 @@ type AlbumInfoMetadata struct {
|
|||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
Batch string `json:"batch,omitempty"`
|
Batch string `json:"batch,omitempty"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
ArtistURL string `json:"artist_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumResponsePayload struct {
|
type AlbumResponsePayload struct {
|
||||||
@@ -211,6 +233,7 @@ type trackSimplified struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
}
|
}
|
||||||
@@ -220,6 +243,7 @@ type trackFull struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
ExternalID externalID `json:"external_ids"`
|
ExternalID externalID `json:"external_ids"`
|
||||||
Album albumSimplified `json:"album"`
|
Album albumSimplified `json:"album"`
|
||||||
@@ -269,11 +293,6 @@ type artistResponse struct {
|
|||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type artistAlbumsResponse struct {
|
|
||||||
Items []albumSimplified `json:"items"`
|
|
||||||
Next string `json:"next"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type playlistRaw struct {
|
type playlistRaw struct {
|
||||||
Data playlistResponse
|
Data playlistResponse
|
||||||
BatchEnabled bool
|
BatchEnabled bool
|
||||||
@@ -319,7 +338,7 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
|||||||
return nil, err
|
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) {
|
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) {
|
switch payload := raw.(type) {
|
||||||
case *playlistRaw:
|
case *playlistRaw:
|
||||||
return c.formatPlaylistData(payload), nil
|
return c.formatPlaylistData(payload), nil
|
||||||
@@ -477,16 +496,38 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
|||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
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{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: item.Track.ID,
|
||||||
Artists: joinArtists(item.Track.Artists),
|
Artists: joinArtists(item.Track.Artists),
|
||||||
Name: item.Track.Name,
|
Name: item.Track.Name,
|
||||||
AlbumName: item.Track.Album.Name,
|
AlbumName: item.Track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
||||||
DurationMS: item.Track.DurationMS,
|
DurationMS: item.Track.DurationMS,
|
||||||
Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images),
|
Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images),
|
||||||
ReleaseDate: item.Track.Album.ReleaseDate,
|
ReleaseDate: item.Track.Album.ReleaseDate,
|
||||||
TrackNumber: item.Track.TrackNumber,
|
TrackNumber: item.Track.TrackNumber,
|
||||||
|
TotalTracks: item.Track.Album.TotalTracks,
|
||||||
|
DiscNumber: item.Track.DiscNumber,
|
||||||
ExternalURL: item.Track.ExternalURL.Spotify,
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||||
ISRC: item.Track.ExternalID.ISRC,
|
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) {
|
func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) {
|
||||||
albumImage := firstImageURL(raw.Data.Images)
|
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{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: raw.Data.TotalTracks,
|
TotalTracks: raw.Data.TotalTracks,
|
||||||
Name: raw.Data.Name,
|
Name: raw.Data.Name,
|
||||||
ReleaseDate: raw.Data.ReleaseDate,
|
ReleaseDate: raw.Data.ReleaseDate,
|
||||||
Artists: joinArtists(raw.Data.Artists),
|
Artists: joinArtists(raw.Data.Artists),
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
|
ArtistID: artistID,
|
||||||
|
ArtistURL: artistURL,
|
||||||
}
|
}
|
||||||
if raw.BatchEnabled {
|
if raw.BatchEnabled {
|
||||||
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
|
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 {
|
for _, item := range raw.Data.Tracks.Items {
|
||||||
isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache)
|
isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache)
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: item.ID,
|
||||||
Artists: joinArtists(item.Artists),
|
Artists: joinArtists(item.Artists),
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
AlbumName: raw.Data.Name,
|
AlbumName: raw.Data.Name,
|
||||||
|
AlbumArtist: joinArtists(raw.Data.Artists),
|
||||||
DurationMS: item.DurationMS,
|
DurationMS: item.DurationMS,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: raw.Data.ReleaseDate,
|
ReleaseDate: raw.Data.ReleaseDate,
|
||||||
TrackNumber: item.TrackNumber,
|
TrackNumber: item.TrackNumber,
|
||||||
|
TotalTracks: raw.Data.TotalTracks,
|
||||||
|
DiscNumber: item.DiscNumber,
|
||||||
ExternalURL: item.ExternalURL.Spotify,
|
ExternalURL: item.ExternalURL.Spotify,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
})
|
})
|
||||||
@@ -577,17 +629,39 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
|
|
||||||
for _, tr := range tracks {
|
for _, tr := range tracks {
|
||||||
isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache)
|
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{
|
allTracks = append(allTracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: tr.ID,
|
||||||
Artists: joinArtists(tr.Artists),
|
Artists: joinArtists(tr.Artists),
|
||||||
Name: tr.Name,
|
Name: tr.Name,
|
||||||
AlbumName: alb.Name,
|
AlbumName: alb.Name,
|
||||||
|
AlbumArtist: joinArtists(alb.Artists),
|
||||||
AlbumType: alb.AlbumType,
|
AlbumType: alb.AlbumType,
|
||||||
DurationMS: tr.DurationMS,
|
DurationMS: tr.DurationMS,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: alb.ReleaseDate,
|
ReleaseDate: alb.ReleaseDate,
|
||||||
TrackNumber: tr.TrackNumber,
|
TrackNumber: tr.TrackNumber,
|
||||||
|
TotalTracks: alb.TotalTracks,
|
||||||
|
DiscNumber: tr.DiscNumber,
|
||||||
ExternalURL: tr.ExternalURL.Spotify,
|
ExternalURL: tr.ExternalURL.Spotify,
|
||||||
ISRC: isrc,
|
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{
|
return TrackResponse{
|
||||||
Track: TrackMetadata{
|
Track: TrackMetadata{
|
||||||
|
SpotifyID: raw.ID,
|
||||||
Artists: joinArtists(raw.Artists),
|
Artists: joinArtists(raw.Artists),
|
||||||
Name: raw.Name,
|
Name: raw.Name,
|
||||||
AlbumName: raw.Album.Name,
|
AlbumName: raw.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(raw.Album.Artists),
|
||||||
DurationMS: raw.DurationMS,
|
DurationMS: raw.DurationMS,
|
||||||
Images: firstImageURL(raw.Album.Images),
|
Images: firstImageURL(raw.Album.Images),
|
||||||
ReleaseDate: raw.Album.ReleaseDate,
|
ReleaseDate: raw.Album.ReleaseDate,
|
||||||
TrackNumber: raw.TrackNumber,
|
TrackNumber: raw.TrackNumber,
|
||||||
|
TotalTracks: raw.Album.TotalTracks,
|
||||||
|
DiscNumber: raw.DiscNumber,
|
||||||
ExternalURL: raw.ExternalURL.Spotify,
|
ExternalURL: raw.ExternalURL.Spotify,
|
||||||
ISRC: raw.ExternalID.ISRC,
|
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) {
|
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 {
|
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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
|||||||
+1194
-143
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<title>SpotiFLAC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+18
-12
@@ -6,45 +6,51 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"postinstall": "node scripts/generate-icon.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate-icon": "node scripts/generate-icon.js"
|
"generate-icon": "node scripts/generate-icon.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.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-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@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",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.0.2",
|
||||||
"@types/react": "^19.2.6",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.47.0",
|
"typescript-eslint": "^8.49.0",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
e00813ca84dd3deaade9854c0df093cd
|
d4b3974abd992c8ff941c6fde9f62062
|
||||||
Generated
+736
-672
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
}
|
|
||||||
+406
-97
@@ -5,50 +5,72 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
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 { applyTheme } from "@/lib/themes";
|
||||||
import { OpenFolder } from "../wailsjs/go/main/App";
|
import { OpenFolder } from "../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { TitleBar } from "@/components/TitleBar";
|
import { TitleBar } from "@/components/TitleBar";
|
||||||
|
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
import { SearchBar } from "@/components/SearchBar";
|
import { SearchBar } from "@/components/SearchBar";
|
||||||
import { TrackInfo } from "@/components/TrackInfo";
|
import { TrackInfo } from "@/components/TrackInfo";
|
||||||
import { AlbumInfo } from "@/components/AlbumInfo";
|
import { AlbumInfo } from "@/components/AlbumInfo";
|
||||||
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
||||||
import { ArtistInfo } from "@/components/ArtistInfo";
|
import { ArtistInfo } from "@/components/ArtistInfo";
|
||||||
|
import { DownloadQueue } from "@/components/DownloadQueue";
|
||||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
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
|
// Hooks
|
||||||
import { useDownload } from "@/hooks/useDownload";
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
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() {
|
function App() {
|
||||||
|
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [sortBy, setSortBy] = useState<string>("default");
|
const [sortBy, setSortBy] = useState<string>("default");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentListPage, setCurrentListPage] = useState(1);
|
||||||
const [hasUpdate, setHasUpdate] = useState(false);
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
|
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
||||||
|
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "5.8";
|
const CURRENT_VERSION = "6.8";
|
||||||
|
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
|
const lyrics = useLyrics();
|
||||||
|
const cover = useCover();
|
||||||
|
const availability = useAvailability();
|
||||||
|
const downloadQueue = useDownloadQueueDialog();
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
applyThemeMode(settings.themeMode);
|
applyThemeMode(settings.themeMode);
|
||||||
applyTheme(settings.theme);
|
applyTheme(settings.theme);
|
||||||
|
applyFont(settings.fontFamily);
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
@@ -61,6 +83,7 @@ function App() {
|
|||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
@@ -71,19 +94,26 @@ function App() {
|
|||||||
setSelectedTracks([]);
|
setSelectedTracks([]);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
download.resetDownloadedTracks();
|
download.resetDownloadedTracks();
|
||||||
|
lyrics.resetLyricsState();
|
||||||
|
cover.resetCoverState();
|
||||||
|
availability.clearAvailability();
|
||||||
setSortBy("default");
|
setSortBy("default");
|
||||||
setCurrentPage(1);
|
setCurrentListPage(1);
|
||||||
}, [metadata.metadata]);
|
}, [metadata.metadata]);
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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 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);
|
setHasUpdate(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -91,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 handleFetchMetadata = async () => {
|
||||||
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
|
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
|
||||||
if (updatedUrl) {
|
if (updatedUrl) {
|
||||||
@@ -98,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) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
setCurrentPage(1);
|
setCurrentListPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTrackSelection = (isrc: string) => {
|
const toggleTrackSelection = (isrc: string) => {
|
||||||
@@ -133,6 +260,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const renderMetadata = () => {
|
const renderMetadata = () => {
|
||||||
if (!metadata.metadata) return null;
|
if (!metadata.metadata) return null;
|
||||||
|
|
||||||
@@ -144,7 +272,19 @@ function App() {
|
|||||||
isDownloading={download.isDownloading}
|
isDownloading={download.isDownloading}
|
||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloaded={download.downloadedTracks.has(track.isrc)}
|
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}
|
onDownload={download.handleDownloadTrack}
|
||||||
|
onDownloadLyrics={lyrics.handleDownloadLyrics}
|
||||||
|
onCheckAvailability={availability.checkAvailability}
|
||||||
|
onDownloadCover={cover.handleDownloadCover}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -160,25 +300,60 @@ function App() {
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={download.downloadedTracks}
|
downloadedTracks={download.downloadedTracks}
|
||||||
|
failedTracks={download.failedTracks}
|
||||||
|
skippedTracks={download.skippedTracks}
|
||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloading={download.isDownloading}
|
isDownloading={download.isDownloading}
|
||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
downloadProgress={download.downloadProgress}
|
downloadProgress={download.downloadProgress}
|
||||||
currentDownloadInfo={download.currentDownloadInfo}
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
currentPage={currentPage}
|
currentPage={currentListPage}
|
||||||
itemsPerPage={ITEMS_PER_PAGE}
|
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}
|
onSearchChange={handleSearchChange}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
onToggleTrack={toggleTrackSelection}
|
onToggleTrack={toggleTrackSelection}
|
||||||
onToggleSelectAll={toggleSelectAll}
|
onToggleSelectAll={toggleSelectAll}
|
||||||
onDownloadTrack={download.handleDownloadTrack}
|
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={() =>
|
onDownloadSelected={() =>
|
||||||
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
|
download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
|
||||||
}
|
}
|
||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,18 +368,41 @@ function App() {
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={download.downloadedTracks}
|
downloadedTracks={download.downloadedTracks}
|
||||||
|
failedTracks={download.failedTracks}
|
||||||
|
skippedTracks={download.skippedTracks}
|
||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloading={download.isDownloading}
|
isDownloading={download.isDownloading}
|
||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
downloadProgress={download.downloadProgress}
|
downloadProgress={download.downloadProgress}
|
||||||
currentDownloadInfo={download.currentDownloadInfo}
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
currentPage={currentPage}
|
currentPage={currentListPage}
|
||||||
itemsPerPage={ITEMS_PER_PAGE}
|
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}
|
onSearchChange={handleSearchChange}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
onToggleTrack={toggleTrackSelection}
|
onToggleTrack={toggleTrackSelection}
|
||||||
onToggleSelectAll={toggleSelectAll}
|
onToggleSelectAll={toggleSelectAll}
|
||||||
onDownloadTrack={download.handleDownloadTrack}
|
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)}
|
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
|
||||||
onDownloadSelected={() =>
|
onDownloadSelected={() =>
|
||||||
download.handleDownloadSelected(
|
download.handleDownloadSelected(
|
||||||
@@ -215,7 +413,20 @@ function App() {
|
|||||||
}
|
}
|
||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -231,26 +442,61 @@ function App() {
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={download.downloadedTracks}
|
downloadedTracks={download.downloadedTracks}
|
||||||
|
failedTracks={download.failedTracks}
|
||||||
|
skippedTracks={download.skippedTracks}
|
||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloading={download.isDownloading}
|
isDownloading={download.isDownloading}
|
||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
downloadProgress={download.downloadProgress}
|
downloadProgress={download.downloadProgress}
|
||||||
currentDownloadInfo={download.currentDownloadInfo}
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
currentPage={currentPage}
|
currentPage={currentListPage}
|
||||||
itemsPerPage={ITEMS_PER_PAGE}
|
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}
|
onSearchChange={handleSearchChange}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
onToggleTrack={toggleTrackSelection}
|
onToggleTrack={toggleTrackSelection}
|
||||||
onToggleSelectAll={toggleSelectAll}
|
onToggleSelectAll={toggleSelectAll}
|
||||||
onDownloadTrack={download.handleDownloadTrack}
|
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={() =>
|
onDownloadSelected={() =>
|
||||||
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
|
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)
|
||||||
}
|
}
|
||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onAlbumClick={metadata.handleAlbumClick}
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,98 +504,161 @@ function App() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
|
||||||
<TitleBar />
|
|
||||||
<div className="flex-1 p-4 md:p-8">
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
|
||||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} />
|
|
||||||
|
|
||||||
{/* Download Progress Toast */}
|
|
||||||
<DownloadProgressToast />
|
|
||||||
|
|
||||||
{/* Timeout Dialog */}
|
const renderPage = () => {
|
||||||
<Dialog
|
switch (currentPage) {
|
||||||
open={metadata.showTimeoutDialog}
|
case "settings":
|
||||||
onOpenChange={metadata.setShowTimeoutDialog}
|
return <SettingsPage />;
|
||||||
>
|
case "debug":
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
return <DebugLoggerPage />;
|
||||||
<DialogHeader>
|
case "audio-analysis":
|
||||||
<DialogTitle>Fetch Settings</DialogTitle>
|
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>
|
<DialogDescription>
|
||||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||||
with large discography.
|
with large discography.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
{metadata.pendingArtistName && (
|
||||||
<div className="space-y-4 py-4">
|
<div className="py-2">
|
||||||
<div className="space-y-2">
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
</div>
|
||||||
<Input
|
)}
|
||||||
id="timeout"
|
<div className="space-y-4 py-4">
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min="10"
|
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||||
max="600"
|
<Input
|
||||||
value={metadata.timeoutValue}
|
id="timeout"
|
||||||
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
|
type="number"
|
||||||
/>
|
min="10"
|
||||||
<p className="text-xs text-muted-foreground">
|
max="600"
|
||||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
value={metadata.timeoutValue}
|
||||||
minutes).
|
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
|
||||||
</p>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||||
|
minutes).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DialogFooter>
|
||||||
<DialogFooter>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
>
|
||||||
>
|
Cancel
|
||||||
Cancel
|
</Button>
|
||||||
</Button>
|
<Button onClick={metadata.handleConfirmFetch}>
|
||||||
<Button onClick={metadata.handleConfirmFetch}>
|
<Search className="h-4 w-4" />
|
||||||
<Search className="h-4 w-4 mr-2" />
|
Fetch
|
||||||
Fetch
|
</Button>
|
||||||
</Button>
|
</DialogFooter>
|
||||||
</DialogFooter>
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Album Fetch Dialog */}
|
{/* Album Fetch Dialog */}
|
||||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
<DialogHeader>
|
<div className="absolute right-4 top-4">
|
||||||
<DialogTitle>Fetch Album</DialogTitle>
|
<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>
|
<DialogDescription>
|
||||||
Do you want to fetch metadata for this album?
|
Do you want to fetch metadata for this album?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
{metadata.selectedAlbum && (
|
||||||
{metadata.selectedAlbum && (
|
<div className="py-2">
|
||||||
<div className="py-4">
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||||
<p className="font-medium">{metadata.selectedAlbum.name}</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<DialogFooter>
|
||||||
<DialogFooter>
|
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
Cancel
|
||||||
Cancel
|
</Button>
|
||||||
</Button>
|
<Button onClick={async () => {
|
||||||
<Button onClick={metadata.handleConfirmAlbumFetch}>
|
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||||
<Search className="h-4 w-4 mr-2" />
|
if (albumUrl) {
|
||||||
Fetch Album
|
setSpotifyUrl(albumUrl);
|
||||||
</Button>
|
}
|
||||||
</DialogFooter>
|
}}>
|
||||||
</DialogContent>
|
<Search className="h-4 w-4" />
|
||||||
</Dialog>
|
Fetch Album
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
url={spotifyUrl}
|
url={spotifyUrl}
|
||||||
loading={metadata.loading}
|
loading={metadata.loading}
|
||||||
onUrlChange={setSpotifyUrl}
|
onUrlChange={setSpotifyUrl}
|
||||||
onFetch={handleFetchMetadata}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Download Progress Toast - Bottom Left */}
|
||||||
|
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||||
|
|
||||||
|
{/* Download Queue Dialog */}
|
||||||
|
<DownloadQueue
|
||||||
|
isOpen={downloadQueue.isOpen}
|
||||||
|
onClose={downloadQueue.closeQueue}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
|
|
||||||
interface AlbumInfoProps {
|
interface AlbumInfoProps {
|
||||||
albumInfo: {
|
albumInfo: {
|
||||||
@@ -14,12 +15,16 @@ interface AlbumInfoProps {
|
|||||||
images: string;
|
images: string;
|
||||||
release_date: string;
|
release_date: string;
|
||||||
total_tracks: number;
|
total_tracks: number;
|
||||||
|
artist_id?: string;
|
||||||
|
artist_url?: string;
|
||||||
};
|
};
|
||||||
trackList: TrackMetadata[];
|
trackList: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
@@ -27,16 +32,38 @@ interface AlbumInfoProps {
|
|||||||
currentDownloadInfo: { name: string; artists: string } | null;
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
itemsPerPage: 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;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => 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;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlbumInfo({
|
export function AlbumInfo({
|
||||||
@@ -46,6 +73,8 @@ export function AlbumInfo({
|
|||||||
sortBy,
|
sortBy,
|
||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
@@ -53,16 +82,35 @@ export function AlbumInfo({
|
|||||||
currentDownloadInfo,
|
currentDownloadInfo,
|
||||||
currentPage,
|
currentPage,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
|
downloadedLyrics,
|
||||||
|
failedLyrics,
|
||||||
|
skippedLyrics,
|
||||||
|
downloadingLyricsTrack,
|
||||||
|
checkingAvailabilityTrack,
|
||||||
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
|
isBulkDownloadingLyrics,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
|
onDownloadLyrics,
|
||||||
|
onDownloadCover,
|
||||||
|
onCheckAvailability,
|
||||||
|
onDownloadAllLyrics,
|
||||||
|
onDownloadAllCovers,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
onStopDownload,
|
onStopDownload,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onArtistClick,
|
||||||
|
onTrackClick,
|
||||||
}: AlbumInfoProps) {
|
}: AlbumInfoProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -81,19 +129,30 @@ export function AlbumInfo({
|
|||||||
<p className="text-sm font-medium">Album</p>
|
<p className="text-sm font-medium">Album</p>
|
||||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<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>•</span>
|
||||||
<span>{albumInfo.release_date}</span>
|
<span>{albumInfo.release_date}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{albumInfo.total_tracks} songs</span>
|
<span>{albumInfo.total_tracks} songs</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||||
onClick={onDownloadAll}
|
|
||||||
className="gap-2"
|
|
||||||
disabled={isDownloading}
|
|
||||||
>
|
|
||||||
{isDownloading && bulkDownloadType === "all" ? (
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
@@ -105,7 +164,6 @@ export function AlbumInfo({
|
|||||||
<Button
|
<Button
|
||||||
onClick={onDownloadSelected}
|
onClick={onDownloadSelected}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-2"
|
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
@@ -116,8 +174,40 @@ export function AlbumInfo({
|
|||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</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 && (
|
{downloadedTracks.size > 0 && (
|
||||||
<Button onClick={onOpenFolder} variant="outline" className="gap-1.5">
|
<Button onClick={onOpenFolder} variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
Open Folder
|
Open Folder
|
||||||
</Button>
|
</Button>
|
||||||
@@ -147,16 +237,33 @@ export function AlbumInfo({
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={downloadedTracks}
|
downloadedTracks={downloadedTracks}
|
||||||
|
failedTracks={failedTracks}
|
||||||
|
skippedTracks={skippedTracks}
|
||||||
downloadingTrack={downloadingTrack}
|
downloadingTrack={downloadingTrack}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
showCheckboxes={true}
|
showCheckboxes={true}
|
||||||
hideAlbumColumn={true}
|
hideAlbumColumn={true}
|
||||||
|
folderName={albumInfo.name}
|
||||||
|
downloadedLyrics={downloadedLyrics}
|
||||||
|
failedLyrics={failedLyrics}
|
||||||
|
skippedLyrics={skippedLyrics}
|
||||||
|
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||||
|
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
||||||
|
availabilityMap={availabilityMap}
|
||||||
onToggleTrack={onToggleTrack}
|
onToggleTrack={onToggleTrack}
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onDownloadLyrics={onDownloadLyrics}
|
||||||
|
onDownloadCover={onDownloadCover}
|
||||||
|
downloadedCovers={downloadedCovers}
|
||||||
|
failedCovers={failedCovers}
|
||||||
|
skippedCovers={skippedCovers}
|
||||||
|
downloadingCoverTrack={downloadingCoverTrack}
|
||||||
|
onCheckAvailability={onCheckAvailability}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
|
onTrackClick={onTrackClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
|
|
||||||
interface ArtistInfoProps {
|
interface ArtistInfoProps {
|
||||||
artistInfo: {
|
artistInfo: {
|
||||||
@@ -27,6 +28,8 @@ interface ArtistInfoProps {
|
|||||||
sortBy: string;
|
sortBy: string;
|
||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
@@ -34,17 +37,39 @@ interface ArtistInfoProps {
|
|||||||
currentDownloadInfo: { name: string; artists: string } | null;
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
itemsPerPage: 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;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => 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;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => 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;
|
onPageChange: (page: number) => void;
|
||||||
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtistInfo({
|
export function ArtistInfo({
|
||||||
@@ -55,6 +80,8 @@ export function ArtistInfo({
|
|||||||
sortBy,
|
sortBy,
|
||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
@@ -62,17 +89,36 @@ export function ArtistInfo({
|
|||||||
currentDownloadInfo,
|
currentDownloadInfo,
|
||||||
currentPage,
|
currentPage,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
|
downloadedLyrics,
|
||||||
|
failedLyrics,
|
||||||
|
skippedLyrics,
|
||||||
|
downloadingLyricsTrack,
|
||||||
|
checkingAvailabilityTrack,
|
||||||
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
|
isBulkDownloadingLyrics,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
|
onDownloadLyrics,
|
||||||
|
onDownloadCover,
|
||||||
|
onCheckAvailability,
|
||||||
|
onDownloadAllLyrics,
|
||||||
|
onDownloadAllCovers,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
onStopDownload,
|
onStopDownload,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onAlbumClick,
|
onAlbumClick,
|
||||||
|
onArtistClick,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onTrackClick,
|
||||||
}: ArtistInfoProps) {
|
}: ArtistInfoProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -89,8 +135,12 @@ export function ArtistInfo({
|
|||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<p className="text-sm font-medium">Artist</p>
|
<p className="text-sm font-medium">Artist</p>
|
||||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
<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>{artistInfo.followers.toLocaleString()} followers</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumList.length} albums</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{trackList.length} tracks</span>
|
||||||
{artistInfo.genres.length > 0 && (
|
{artistInfo.genres.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
@@ -140,15 +190,10 @@ export function ArtistInfo({
|
|||||||
|
|
||||||
{trackList.length > 0 && (
|
{trackList.length > 0 && (
|
||||||
<div className="space-y-4">
|
<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>
|
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||||
onClick={onDownloadAll}
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
disabled={isDownloading}
|
|
||||||
>
|
|
||||||
{isDownloading && bulkDownloadType === "all" ? (
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
@@ -161,7 +206,6 @@ export function ArtistInfo({
|
|||||||
onClick={onDownloadSelected}
|
onClick={onDownloadSelected}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-2"
|
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
@@ -172,8 +216,42 @@ export function ArtistInfo({
|
|||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</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 && (
|
{downloadedTracks.size > 0 && (
|
||||||
<Button onClick={onOpenFolder} size="sm" variant="outline" className="gap-1.5">
|
<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
Open Folder
|
Open Folder
|
||||||
</Button>
|
</Button>
|
||||||
@@ -199,16 +277,36 @@ export function ArtistInfo({
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={downloadedTracks}
|
downloadedTracks={downloadedTracks}
|
||||||
|
failedTracks={failedTracks}
|
||||||
|
skippedTracks={skippedTracks}
|
||||||
downloadingTrack={downloadingTrack}
|
downloadingTrack={downloadingTrack}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
showCheckboxes={true}
|
showCheckboxes={true}
|
||||||
hideAlbumColumn={false}
|
hideAlbumColumn={false}
|
||||||
|
folderName={artistInfo.name}
|
||||||
|
isArtistDiscography={true}
|
||||||
|
downloadedLyrics={downloadedLyrics}
|
||||||
|
failedLyrics={failedLyrics}
|
||||||
|
skippedLyrics={skippedLyrics}
|
||||||
|
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||||
|
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
||||||
|
availabilityMap={availabilityMap}
|
||||||
onToggleTrack={onToggleTrack}
|
onToggleTrack={onToggleTrack}
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onDownloadLyrics={onDownloadLyrics}
|
||||||
|
onDownloadCover={onDownloadCover}
|
||||||
|
downloadedCovers={downloadedCovers}
|
||||||
|
failedCovers={failedCovers}
|
||||||
|
skippedCovers={skippedCovers}
|
||||||
|
downloadingCoverTrack={downloadingCoverTrack}
|
||||||
|
onCheckAvailability={onCheckAvailability}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
|
onAlbumClick={onAlbumClick}
|
||||||
|
onArtistClick={onArtistClick}
|
||||||
|
onTrackClick={onTrackClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,48 @@
|
|||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
import { Download } from "lucide-react";
|
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||||
|
import { Download, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function DownloadProgressToast() {
|
interface DownloadProgressToastProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
||||||
const progress = useDownloadProgress();
|
const progress = useDownloadProgress();
|
||||||
|
const queueInfo = useDownloadQueueData();
|
||||||
|
|
||||||
if (!progress.is_downloading) {
|
// 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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
<div className="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">
|
||||||
<div className="bg-background border rounded-lg shadow-lg p-3">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<Download className="h-4 w-4 text-primary animate-bounce" />
|
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col min-w-[80px]">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium font-mono tabular-nums">
|
||||||
{progress.mb_downloaded.toFixed(2)} MB
|
{progress.mb_downloaded.toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
{progress.speed_mbps > 0 && (
|
{progress.speed_mbps > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||||
{progress.speed_mbps.toFixed(2)} MB/s
|
{progress.speed_mbps.toFixed(2)} MB/s
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Settings } from "@/components/Settings";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import { formatRelativeTime } from "@/lib/relative-time";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
version: string;
|
version: string;
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
|
releaseDate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ version, hasUpdate }: HeaderProps) {
|
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
@@ -30,16 +31,24 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
|||||||
SpotiFLAC
|
SpotiFLAC
|
||||||
</h1>
|
</h1>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Badge variant="default" asChild>
|
<Tooltip>
|
||||||
<a
|
<TooltipTrigger asChild>
|
||||||
href="https://github.com/afkarxyz/SpotiFLAC/releases"
|
<Badge variant="default" asChild>
|
||||||
target="_blank"
|
<button
|
||||||
rel="noopener noreferrer"
|
type="button"
|
||||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
|
||||||
>
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
v{version}
|
>
|
||||||
</a>
|
v{version}
|
||||||
</Badge>
|
</button>
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{hasUpdate && releaseDate && (
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{formatRelativeTime(releaseDate)}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
{hasUpdate && (
|
{hasUpdate && (
|
||||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
<span className="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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Get Spotify tracks in true FLAC from Tidal, Deezer & Qobuz — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 top-0 flex gap-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" asChild>
|
|
||||||
<a
|
|
||||||
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="GitHub Issues"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<p>Report bug or request feature</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Settings />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
|
|
||||||
interface PlaylistInfoProps {
|
interface PlaylistInfoProps {
|
||||||
playlistInfo: {
|
playlistInfo: {
|
||||||
@@ -26,6 +27,8 @@ interface PlaylistInfoProps {
|
|||||||
sortBy: string;
|
sortBy: string;
|
||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
@@ -33,16 +36,39 @@ interface PlaylistInfoProps {
|
|||||||
currentDownloadInfo: { name: string; artists: string } | null;
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
itemsPerPage: 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;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => 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;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
onPageChange: (page: number) => 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({
|
export function PlaylistInfo({
|
||||||
@@ -52,6 +78,8 @@ export function PlaylistInfo({
|
|||||||
sortBy,
|
sortBy,
|
||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
@@ -59,16 +87,36 @@ export function PlaylistInfo({
|
|||||||
currentDownloadInfo,
|
currentDownloadInfo,
|
||||||
currentPage,
|
currentPage,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
|
downloadedLyrics,
|
||||||
|
failedLyrics,
|
||||||
|
skippedLyrics,
|
||||||
|
downloadingLyricsTrack,
|
||||||
|
checkingAvailabilityTrack,
|
||||||
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
|
isBulkDownloadingLyrics,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
|
onDownloadLyrics,
|
||||||
|
onDownloadCover,
|
||||||
|
onCheckAvailability,
|
||||||
|
onDownloadAllLyrics,
|
||||||
|
onDownloadAllCovers,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
onStopDownload,
|
onStopDownload,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onAlbumClick,
|
||||||
|
onArtistClick,
|
||||||
|
onTrackClick,
|
||||||
}: PlaylistInfoProps) {
|
}: PlaylistInfoProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -94,12 +142,8 @@ export function PlaylistInfo({
|
|||||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||||
onClick={onDownloadAll}
|
|
||||||
className="gap-2"
|
|
||||||
disabled={isDownloading}
|
|
||||||
>
|
|
||||||
{isDownloading && bulkDownloadType === "all" ? (
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
@@ -111,7 +155,6 @@ export function PlaylistInfo({
|
|||||||
<Button
|
<Button
|
||||||
onClick={onDownloadSelected}
|
onClick={onDownloadSelected}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-2"
|
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
>
|
>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
@@ -122,8 +165,40 @@ export function PlaylistInfo({
|
|||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</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 && (
|
{downloadedTracks.size > 0 && (
|
||||||
<Button onClick={onOpenFolder} variant="outline" className="gap-1.5">
|
<Button onClick={onOpenFolder} variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
Open Folder
|
Open Folder
|
||||||
</Button>
|
</Button>
|
||||||
@@ -153,16 +228,35 @@ export function PlaylistInfo({
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={downloadedTracks}
|
downloadedTracks={downloadedTracks}
|
||||||
|
failedTracks={failedTracks}
|
||||||
|
skippedTracks={skippedTracks}
|
||||||
downloadingTrack={downloadingTrack}
|
downloadingTrack={downloadingTrack}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
showCheckboxes={true}
|
showCheckboxes={true}
|
||||||
hideAlbumColumn={false}
|
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}
|
onToggleTrack={onToggleTrack}
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onDownloadLyrics={onDownloadLyrics}
|
||||||
|
onDownloadCover={onDownloadCover}
|
||||||
|
onCheckAvailability={onCheckAvailability}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
|
onAlbumClick={onAlbumClick}
|
||||||
|
onArtistClick={onArtistClick}
|
||||||
|
onTrackClick={onTrackClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Search, ArrowUpDown } from "lucide-react";
|
import { Search, ArrowUpDown, XCircle } from "lucide-react";
|
||||||
|
|
||||||
interface SearchAndSortProps {
|
interface SearchAndSortProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -29,8 +29,17 @@ export function SearchAndSort({
|
|||||||
placeholder="Search tracks..."
|
placeholder="Search tracks..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
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>
|
</div>
|
||||||
<Select value={sortBy} onValueChange={onSortChange}>
|
<Select value={sortBy} onValueChange={onSortChange}>
|
||||||
<SelectTrigger className="w-[200px] gap-1.5">
|
<SelectTrigger className="w-[200px] gap-1.5">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Search, Info, XCircle } from "lucide-react";
|
import { Search, Info, XCircle } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
@@ -9,66 +8,87 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { FetchHistory } from "@/components/FetchHistory";
|
||||||
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
url: string;
|
url: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUrlChange: (url: string) => void;
|
onUrlChange: (url: string) => void;
|
||||||
onFetch: () => 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 (
|
return (
|
||||||
<Card>
|
<div className="space-y-3">
|
||||||
<CardContent className="px-6 py-6 space-y-4">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Label htmlFor="spotify-url">Spotify URL</Label>
|
||||||
<Label htmlFor="spotify-url">Spotify URL</Label>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right">
|
||||||
<TooltipContent side="right">
|
<p>Supports track, album, playlist, and artist URLs</p>
|
||||||
<p>Supports track, album, playlist, and artist URLs</p>
|
<p className="mt-1">Note: Playlist must be public (not private)</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
</CardContent>
|
<div className="flex gap-2">
|
||||||
</Card>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,374 +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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info } 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-[700px] flex flex-col" aria-describedby={undefined}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Settings</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid grid-cols-2 gap-6 py-2">
|
|
||||||
{/* Left Column */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Download Path */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="download-path">Download Path</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="download-path"
|
|
||||||
value={tempSettings.downloadPath}
|
|
||||||
onChange={(e) => handleDownloadPathChange(e.target.value)}
|
|
||||||
placeholder="C:\Users\YourUsername\Music"
|
|
||||||
/>
|
|
||||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Source Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="downloader">Source</Label>
|
|
||||||
<Select
|
|
||||||
value={tempSettings.downloader}
|
|
||||||
onValueChange={handleDownloaderChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="downloader">
|
|
||||||
<SelectValue placeholder="Select a source" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
|
||||||
<SelectItem value="tidal">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<TidalIcon />
|
|
||||||
Tidal
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="deezer">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<DeezerIcon />
|
|
||||||
Deezer
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="qobuz">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<QobuzIcon />
|
|
||||||
Qobuz
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme Mode Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="theme-mode">Theme</Label>
|
|
||||||
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
|
||||||
<SelectTrigger id="theme-mode">
|
|
||||||
<SelectValue placeholder="Select theme mode" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
|
||||||
<SelectItem value="light">Light</SelectItem>
|
|
||||||
<SelectItem value="dark">Dark</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme Color Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="theme">Theme Color</Label>
|
|
||||||
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
|
||||||
<SelectTrigger id="theme">
|
|
||||||
<SelectValue placeholder="Select a theme" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{themes.map((theme) => (
|
|
||||||
<SelectItem key={theme.name} value={theme.name}>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: theme.cssVars.light.primary }}
|
|
||||||
/>
|
|
||||||
{theme.label}
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Filename Format */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm">Filename Format</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={tempSettings.filenameFormat}
|
|
||||||
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="title-artist" id="title-artist" />
|
|
||||||
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="artist-title" id="artist-title" />
|
|
||||||
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="title" id="title" />
|
|
||||||
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
{/* Folder Settings */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-medium text-sm">Folder Settings</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="track-number"
|
|
||||||
checked={tempSettings.trackNumber}
|
|
||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
<p className="text-xs whitespace-nowrap">Adds track numbers based on the order in the album, playlist, or discography list</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { X, Minus, Maximize } from "lucide-react";
|
||||||
import { X, Minus, Square } from "lucide-react";
|
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
|
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleMinimize = () => {
|
const handleMinimize = () => {
|
||||||
WindowMinimise();
|
WindowMinimise();
|
||||||
};
|
};
|
||||||
@@ -18,43 +15,41 @@ export function TitleBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 left-4 z-50 flex gap-2">
|
<>
|
||||||
<button
|
{/* Draggable area */}
|
||||||
onClick={handleClose}
|
<div
|
||||||
onMouseEnter={() => setHoveredButton("close")}
|
className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm"
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
|
||||||
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
|
onDoubleClick={handleMaximize}
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
/>
|
||||||
aria-label="Close"
|
|
||||||
>
|
{/* Window control buttons - Windows style, right side */}
|
||||||
{hoveredButton === "close" && (
|
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||||
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
|
<button
|
||||||
)}
|
onClick={handleMinimize}
|
||||||
</button>
|
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||||
<button
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
onClick={handleMinimize}
|
aria-label="Minimize"
|
||||||
onMouseEnter={() => setHoveredButton("minimize")}
|
>
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
<Minus className="w-3.5 h-3.5" />
|
||||||
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
|
</button>
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
<button
|
||||||
aria-label="Minimize"
|
onClick={handleMaximize}
|
||||||
>
|
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||||
{hoveredButton === "minimize" && (
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
|
aria-label="Maximize"
|
||||||
)}
|
>
|
||||||
</button>
|
<Maximize className="w-3.5 h-3.5" />
|
||||||
<button
|
</button>
|
||||||
onClick={handleMaximize}
|
<button
|
||||||
onMouseEnter={() => setHoveredButton("maximize")}
|
onClick={handleClose}
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
|
||||||
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
aria-label="Close"
|
||||||
aria-label="Maximize"
|
>
|
||||||
>
|
<X className="w-3.5 h-3.5" />
|
||||||
{hoveredButton === "maximize" && (
|
</button>
|
||||||
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
|
</div>
|
||||||
)}
|
</>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 { 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 {
|
interface TrackInfoProps {
|
||||||
track: TrackMetadata & { album_name: string; release_date: string };
|
track: TrackMetadata & { album_name: string; release_date: string };
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloaded: boolean;
|
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;
|
onOpenFolder: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,23 +37,74 @@ export function TrackInfo({
|
|||||||
isDownloading,
|
isDownloading,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloaded,
|
isDownloaded,
|
||||||
|
isFailed,
|
||||||
|
isSkipped,
|
||||||
|
downloadingLyricsTrack,
|
||||||
|
downloadedLyrics,
|
||||||
|
failedLyrics,
|
||||||
|
skippedLyrics,
|
||||||
|
checkingAvailability,
|
||||||
|
availability,
|
||||||
|
downloadingCover,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onDownloadLyrics,
|
||||||
|
onCheckAvailability,
|
||||||
|
onDownloadCover,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
}: TrackInfoProps) {
|
}: TrackInfoProps) {
|
||||||
|
const [isHoveringCover, setIsHoveringCover] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{track.images && (
|
<div
|
||||||
<img
|
className="shrink-0 relative"
|
||||||
src={track.images}
|
onMouseEnter={() => setIsHoveringCover(true)}
|
||||||
alt={track.name}
|
onMouseLeave={() => setIsHoveringCover(false)}
|
||||||
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0"
|
>
|
||||||
/>
|
{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="flex-1 space-y-4 min-w-0">
|
||||||
<div className="space-y-1">
|
<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>
|
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
@@ -48,10 +118,9 @@ export function TrackInfo({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{track.isrc && (
|
{track.isrc && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button
|
<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)}
|
||||||
className="gap-1.5"
|
|
||||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
>
|
>
|
||||||
{downloadingTrack === track.isrc ? (
|
{downloadingTrack === track.isrc ? (
|
||||||
@@ -63,8 +132,64 @@ export function TrackInfo({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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 && (
|
{isDownloaded && (
|
||||||
<Button onClick={onOpenFolder} variant="outline" className="gap-1.5">
|
<Button onClick={onOpenFolder} variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
Open Folder
|
Open Folder
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
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 { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -10,7 +15,8 @@ import {
|
|||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from "@/components/ui/pagination";
|
} 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 {
|
interface TrackListProps {
|
||||||
tracks: TrackMetadata[];
|
tracks: TrackMetadata[];
|
||||||
@@ -18,16 +24,39 @@ interface TrackListProps {
|
|||||||
sortBy: string;
|
sortBy: string;
|
||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
showCheckboxes?: boolean;
|
showCheckboxes?: boolean;
|
||||||
hideAlbumColumn?: 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;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => 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;
|
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({
|
export function TrackList({
|
||||||
@@ -36,16 +65,36 @@ export function TrackList({
|
|||||||
sortBy,
|
sortBy,
|
||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
currentPage,
|
currentPage,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
showCheckboxes = false,
|
showCheckboxes = false,
|
||||||
hideAlbumColumn = false,
|
hideAlbumColumn = false,
|
||||||
|
folderName,
|
||||||
|
isArtistDiscography = false,
|
||||||
|
downloadedLyrics,
|
||||||
|
failedLyrics,
|
||||||
|
skippedLyrics,
|
||||||
|
downloadingLyricsTrack,
|
||||||
|
checkingAvailabilityTrack,
|
||||||
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
|
onDownloadLyrics,
|
||||||
|
onCheckAvailability,
|
||||||
|
onDownloadCover,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onAlbumClick,
|
||||||
|
onArtistClick,
|
||||||
|
onTrackClick,
|
||||||
}: TrackListProps) {
|
}: TrackListProps) {
|
||||||
let filteredTracks = tracks.filter((track) => {
|
let filteredTracks = tracks.filter((track) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
@@ -161,45 +210,221 @@ export function TrackList({
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{track.name}</span>
|
{onTrackClick ? (
|
||||||
{downloadedTracks.has(track.isrc) && (
|
<span
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
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>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{!hideAlbumColumn && (
|
{!hideAlbumColumn && (
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
<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>
|
||||||
)}
|
)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||||
{formatDuration(track.duration_ms)}
|
{formatDuration(track.duration_ms)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle text-center">
|
<td className="p-4 align-middle text-center">
|
||||||
{track.isrc && (
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Button
|
{track.isrc && (
|
||||||
onClick={() =>
|
<Tooltip>
|
||||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name)
|
<TooltipTrigger asChild>
|
||||||
}
|
<Button
|
||||||
size="sm"
|
onClick={() =>
|
||||||
className="gap-1.5"
|
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)
|
||||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
}
|
||||||
>
|
size="sm"
|
||||||
{downloadingTrack === track.isrc ? (
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
<Spinner />
|
>
|
||||||
) : (
|
{downloadingTrack === track.isrc ? (
|
||||||
<>
|
<Spinner />
|
||||||
<Download className="h-4 w-4" />
|
) : skippedTracks.has(track.isrc) ? (
|
||||||
Download
|
<FileCheck className="h-4 w-4" />
|
||||||
</>
|
) : downloadedTracks.has(track.isrc) ? (
|
||||||
)}
|
<CheckCircle className="h-4 w-4" />
|
||||||
</Button>
|
) : 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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 }
|
||||||
@@ -36,6 +36,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "var(--border)",
|
||||||
"--border-radius": "var(--radius)",
|
"--border-radius": "var(--radius)",
|
||||||
|
left: "calc(56px + 1rem)",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { downloadTrack } from "@/lib/api";
|
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 { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
export function useDownload() {
|
export function useDownload() {
|
||||||
@@ -11,6 +12,8 @@ export function useDownload() {
|
|||||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||||
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
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<{
|
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -19,137 +22,559 @@ export function useDownload() {
|
|||||||
|
|
||||||
const downloadWithAutoFallback = async (
|
const downloadWithAutoFallback = async (
|
||||||
isrc: string,
|
isrc: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
settings: any,
|
settings: any,
|
||||||
trackName?: string,
|
trackName?: string,
|
||||||
artistName?: string,
|
artistName?: string,
|
||||||
albumName?: string,
|
albumName?: string,
|
||||||
playlistName?: string,
|
playlistName?: string,
|
||||||
isArtistDiscography?: boolean,
|
position?: number,
|
||||||
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 query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
|
|
||||||
let outputDir = settings.downloadPath;
|
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) {
|
if (playlistName) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
|
}
|
||||||
|
|
||||||
if (isArtistDiscography) {
|
// Apply folder template if available
|
||||||
if (settings.albumSubfolder && albumName) {
|
if (settings.folderTemplate) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||||
}
|
if (folderPath) {
|
||||||
} else {
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
if (settings.artistSubfolder && artistName) {
|
for (const part of parts) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
|
// 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) {
|
// Use album track number if template contains {album}
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
if (settings.folderTemplate.includes("{album}")) {
|
||||||
}
|
useAlbumTrackNumber = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (service === "auto") {
|
// Always add item to queue before downloading
|
||||||
// Try Tidal first
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||||
try {
|
const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
|
||||||
const tidalResponse = await downloadTrack({
|
|
||||||
isrc,
|
|
||||||
service: "tidal",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: artistName,
|
|
||||||
album_name: albumName,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameFormat,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tidalResponse.success) {
|
if (service === "auto") {
|
||||||
return tidalResponse;
|
// 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
|
// Convert duration from ms to seconds for backend
|
||||||
try {
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
const deezerResponse = await downloadTrack({
|
|
||||||
isrc,
|
|
||||||
service: "deezer",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: artistName,
|
|
||||||
album_name: albumName,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameFormat,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (deezerResponse.success) {
|
// Try Tidal first
|
||||||
return deezerResponse;
|
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
|
// 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,
|
isrc,
|
||||||
service: service as "deezer" | "tidal" | "qobuz",
|
service: service as "tidal" | "qobuz" | "amazon",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: artistName,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
|
album_artist: albumArtist,
|
||||||
|
release_date: releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameFormat,
|
filename_format: settings.filenameTemplate,
|
||||||
track_number: settings.trackNumber,
|
track_number: settings.trackNumber,
|
||||||
position,
|
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 (
|
const handleDownloadTrack = async (
|
||||||
isrc: string,
|
isrc: string,
|
||||||
trackName?: string,
|
trackName?: string,
|
||||||
artistName?: 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) {
|
if (!isrc) {
|
||||||
toast.error("No ISRC found for this track");
|
toast.error("No ISRC found for this track");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`starting download: ${trackName} - ${artistName}`);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setDownloadingTrack(isrc);
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Single track download - no position parameter
|
// 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(
|
const response = await downloadWithAutoFallback(
|
||||||
isrc,
|
isrc,
|
||||||
settings,
|
settings,
|
||||||
trackName,
|
trackName,
|
||||||
artistName,
|
artistName,
|
||||||
albumName,
|
albumName,
|
||||||
undefined,
|
playlistName,
|
||||||
false,
|
position, // Pass position for track numbering
|
||||||
undefined // Don't pass position for single track
|
spotifyId,
|
||||||
|
durationMs,
|
||||||
|
releaseYear,
|
||||||
|
albumArtist || "",
|
||||||
|
releaseDate,
|
||||||
|
coverUrl,
|
||||||
|
spotifyTrackNumber, // Spotify album track number
|
||||||
|
spotifyDiscNumber, // Spotify disc number
|
||||||
|
spotifyTotalTracks // Total tracks in album
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
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));
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
|
setFailedTracks((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(isrc);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Download failed");
|
toast.error(response.error || "Download failed");
|
||||||
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Download failed");
|
toast.error(err instanceof Error ? err.message : "Download failed");
|
||||||
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
}
|
}
|
||||||
@@ -158,21 +583,37 @@ export function useDownload() {
|
|||||||
const handleDownloadSelected = async (
|
const handleDownloadSelected = async (
|
||||||
selectedTracks: string[],
|
selectedTracks: string[],
|
||||||
allTracks: TrackMetadata[],
|
allTracks: TrackMetadata[],
|
||||||
playlistName?: string,
|
folderName?: string,
|
||||||
isArtistDiscography?: boolean
|
isAlbum?: boolean
|
||||||
) => {
|
) => {
|
||||||
if (selectedTracks.length === 0) {
|
if (selectedTracks.length === 0) {
|
||||||
toast.error("No tracks selected");
|
toast.error("No tracks selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`starting batch download: ${selectedTracks.length} selected tracks`);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("selected");
|
setBulkDownloadType("selected");
|
||||||
setDownloadProgress(0);
|
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 successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
const total = selectedTracks.length;
|
const total = selectedTracks.length;
|
||||||
|
|
||||||
for (let i = 0; i < selectedTracks.length; i++) {
|
for (let i = 0; i < selectedTracks.length; i++) {
|
||||||
@@ -185,6 +626,7 @@ export function useDownload() {
|
|||||||
|
|
||||||
const isrc = selectedTracks[i];
|
const isrc = selectedTracks[i];
|
||||||
const track = allTracks.find((t) => t.isrc === isrc);
|
const track = allTracks.find((t) => t.isrc === isrc);
|
||||||
|
const itemID = itemIDs[i];
|
||||||
|
|
||||||
setDownloadingTrack(isrc);
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
@@ -193,26 +635,58 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use sequential numbering (1, 2, 3...) for selected tracks
|
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
||||||
const response = await downloadWithAutoFallback(
|
const releaseYear = track?.release_date?.substring(0, 4);
|
||||||
|
|
||||||
|
// Download with pre-created itemID
|
||||||
|
const response = await downloadWithItemID(
|
||||||
isrc,
|
isrc,
|
||||||
settings,
|
settings,
|
||||||
|
itemID,
|
||||||
track?.name,
|
track?.name,
|
||||||
track?.artists,
|
track?.artists,
|
||||||
track?.album_name,
|
track?.album_name,
|
||||||
playlistName,
|
folderName,
|
||||||
isArtistDiscography,
|
i + 1, // Sequential position based on selection order
|
||||||
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) {
|
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));
|
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 {
|
} else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
logger.error(`failed: ${track?.name} - ${track?.artists}`);
|
||||||
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorCount++;
|
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));
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||||
@@ -224,17 +698,34 @@ export function useDownload() {
|
|||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
shouldStopDownloadRef.current = false;
|
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`);
|
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 {
|
} 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 (
|
const handleDownloadAll = async (
|
||||||
tracks: TrackMetadata[],
|
tracks: TrackMetadata[],
|
||||||
playlistName?: string,
|
folderName?: string,
|
||||||
isArtistDiscography?: boolean
|
isAlbum?: boolean
|
||||||
) => {
|
) => {
|
||||||
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
||||||
|
|
||||||
@@ -243,13 +734,28 @@ export function useDownload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("all");
|
setBulkDownloadType("all");
|
||||||
setDownloadProgress(0);
|
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 successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
const total = tracksWithIsrc.length;
|
const total = tracksWithIsrc.length;
|
||||||
|
|
||||||
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
||||||
@@ -261,30 +767,63 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const track = tracksWithIsrc[i];
|
const track = tracksWithIsrc[i];
|
||||||
|
const itemID = itemIDs[i];
|
||||||
|
|
||||||
setDownloadingTrack(track.isrc);
|
setDownloadingTrack(track.isrc);
|
||||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
|
|
||||||
try {
|
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,
|
track.isrc,
|
||||||
settings,
|
settings,
|
||||||
|
itemID,
|
||||||
track.name,
|
track.name,
|
||||||
track.artists,
|
track.artists,
|
||||||
track.album_name,
|
track.album_name,
|
||||||
playlistName,
|
folderName,
|
||||||
isArtistDiscography,
|
i + 1,
|
||||||
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) {
|
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));
|
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 {
|
} else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
logger.error(`failed: ${track.name} - ${track.artists}`);
|
||||||
|
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorCount++;
|
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));
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||||
@@ -296,20 +835,40 @@ export function useDownload() {
|
|||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
shouldStopDownloadRef.current = false;
|
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`);
|
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 {
|
} 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 = () => {
|
const handleStopDownload = () => {
|
||||||
|
logger.info("download stopped by user");
|
||||||
shouldStopDownloadRef.current = true;
|
shouldStopDownloadRef.current = true;
|
||||||
toast.info("Stopping download...");
|
toast.info("Stopping download...");
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetDownloadedTracks = () => {
|
const resetDownloadedTracks = () => {
|
||||||
setDownloadedTracks(new Set());
|
setDownloadedTracks(new Set());
|
||||||
|
setFailedTracks(new Set());
|
||||||
|
setSkippedTracks(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -318,6 +877,8 @@ export function useDownload() {
|
|||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
currentDownloadInfo,
|
currentDownloadInfo,
|
||||||
handleDownloadTrack,
|
handleDownloadTrack,
|
||||||
handleDownloadSelected,
|
handleDownloadSelected,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
|
|
||||||
export function useMetadata() {
|
export function useMetadata() {
|
||||||
@@ -15,17 +16,52 @@ export function useMetadata() {
|
|||||||
name: string;
|
name: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
} | null>(null);
|
} | 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 fetchMetadataDirectly = async (url: string) => {
|
||||||
|
const urlType = getUrlType(url);
|
||||||
|
logger.info(`fetching ${urlType} metadata...`);
|
||||||
|
logger.debug(`url: ${url}`);
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
const data = await fetchSpotifyMetadata(url);
|
const data = await fetchSpotifyMetadata(url);
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
|
||||||
setMetadata(data);
|
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");
|
toast.success("Metadata fetched successfully");
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -33,6 +69,7 @@ export function useMetadata() {
|
|||||||
|
|
||||||
const handleFetchMetadata = async (url: string) => {
|
const handleFetchMetadata = async (url: string) => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
|
logger.warning("empty url provided");
|
||||||
toast.error("Please enter a Spotify URL");
|
toast.error("Please enter a Spotify URL");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,10 +79,13 @@ export function useMetadata() {
|
|||||||
|
|
||||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||||
|
logger.debug("converted to discography url");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArtistUrl) {
|
if (isArtistUrl) {
|
||||||
|
logger.info("artist url detected, showing timeout dialog");
|
||||||
setPendingUrl(urlToFetch);
|
setPendingUrl(urlToFetch);
|
||||||
|
setPendingArtistName(null); // Clear artist name for URL input
|
||||||
setShowTimeoutDialog(true);
|
setShowTimeoutDialog(true);
|
||||||
} else {
|
} else {
|
||||||
await fetchMetadataDirectly(urlToFetch);
|
await fetchMetadataDirectly(urlToFetch);
|
||||||
@@ -56,15 +96,30 @@ export function useMetadata() {
|
|||||||
|
|
||||||
const handleConfirmFetch = async () => {
|
const handleConfirmFetch = async () => {
|
||||||
setShowTimeoutDialog(false);
|
setShowTimeoutDialog(false);
|
||||||
|
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
||||||
|
logger.debug(`url: ${pendingUrl}`);
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
|
||||||
setMetadata(data);
|
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");
|
toast.success("Metadata fetched successfully");
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -75,23 +130,54 @@ export function useMetadata() {
|
|||||||
name: string;
|
name: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
logger.debug(`album clicked: ${album.name}`);
|
||||||
setSelectedAlbum(album);
|
setSelectedAlbum(album);
|
||||||
setShowAlbumDialog(true);
|
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 () => {
|
const handleConfirmAlbumFetch = async () => {
|
||||||
if (!selectedAlbum) return;
|
if (!selectedAlbum) return;
|
||||||
|
|
||||||
|
const albumUrl = selectedAlbum.external_urls;
|
||||||
|
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
||||||
|
logger.debug(`url: ${albumUrl}`);
|
||||||
|
|
||||||
setShowAlbumDialog(false);
|
setShowAlbumDialog(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
|
|
||||||
try {
|
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);
|
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");
|
toast.success("Album metadata fetched successfully");
|
||||||
|
return albumUrl;
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setSelectedAlbum(null);
|
setSelectedAlbum(null);
|
||||||
@@ -108,9 +194,11 @@ export function useMetadata() {
|
|||||||
showAlbumDialog,
|
showAlbumDialog,
|
||||||
setShowAlbumDialog,
|
setShowAlbumDialog,
|
||||||
selectedAlbum,
|
selectedAlbum,
|
||||||
|
pendingArtistName,
|
||||||
handleFetchMetadata,
|
handleFetchMetadata,
|
||||||
handleConfirmFetch,
|
handleConfirmFetch,
|
||||||
handleAlbumClick,
|
handleAlbumClick,
|
||||||
handleConfirmAlbumFetch,
|
handleConfirmAlbumFetch,
|
||||||
|
handleArtistClick,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-40
@@ -26,19 +26,6 @@
|
|||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--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 {
|
:root {
|
||||||
@@ -61,19 +48,6 @@
|
|||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 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 {
|
.dark {
|
||||||
@@ -95,19 +69,6 @@
|
|||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--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 {
|
@layer base {
|
||||||
@@ -119,10 +80,14 @@
|
|||||||
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
code, pre, .font-mono {
|
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 */
|
/* Sonner Toast Styling */
|
||||||
[data-sonner-toast] {
|
[data-sonner-toast] {
|
||||||
@apply rounded-lg shadow-lg border;
|
@apply rounded-lg shadow-lg border;
|
||||||
@@ -160,6 +125,54 @@
|
|||||||
@apply text-blue-600;
|
@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 mode toast styling */
|
||||||
.dark [data-sonner-toast][data-type="success"] {
|
.dark [data-sonner-toast][data-type="success"] {
|
||||||
@apply bg-green-950 border-green-800 text-green-100;
|
@apply bg-green-950 border-green-800 text-green-100;
|
||||||
@@ -192,3 +205,51 @@
|
|||||||
.dark [data-sonner-toast][data-type="info"] [data-icon] {
|
.dark [data-sonner-toast][data-type="info"] [data-icon] {
|
||||||
@apply text-blue-400;
|
@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
@@ -3,8 +3,12 @@ import type {
|
|||||||
DownloadRequest,
|
DownloadRequest,
|
||||||
DownloadResponse,
|
DownloadResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
|
LyricsDownloadRequest,
|
||||||
|
LyricsDownloadResponse,
|
||||||
|
CoverDownloadRequest,
|
||||||
|
CoverDownloadResponse,
|
||||||
} from "@/types/api";
|
} 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";
|
import { main } from "../../wailsjs/go/models";
|
||||||
|
|
||||||
export async function fetchSpotifyMetadata(
|
export async function fetchSpotifyMetadata(
|
||||||
@@ -39,3 +43,17 @@ export async function checkHealth(): Promise<HealthResponse> {
|
|||||||
time: new Date().toISOString(),
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -1,17 +1,70 @@
|
|||||||
import { GetDefaults } from "../../wailsjs/go/main/App";
|
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 {
|
export interface Settings {
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
downloader: "auto" | "deezer" | "tidal" | "qobuz";
|
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||||
theme: string;
|
theme: string;
|
||||||
themeMode: "auto" | "light" | "dark";
|
themeMode: "auto" | "light" | "dark";
|
||||||
filenameFormat: "title-artist" | "artist-title" | "title";
|
fontFamily: FontFamily;
|
||||||
artistSubfolder: boolean;
|
// New template system
|
||||||
albumSubfolder: boolean;
|
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;
|
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
|
// Auto-detect operating system
|
||||||
function detectOS(): "Windows" | "linux/MacOS" {
|
function detectOS(): "Windows" | "linux/MacOS" {
|
||||||
const platform = window.navigator.platform.toLowerCase();
|
const platform = window.navigator.platform.toLowerCase();
|
||||||
@@ -26,13 +79,47 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
downloader: "auto",
|
downloader: "auto",
|
||||||
theme: "yellow",
|
theme: "yellow",
|
||||||
themeMode: "auto",
|
themeMode: "auto",
|
||||||
filenameFormat: "title-artist",
|
fontFamily: "google-sans",
|
||||||
artistSubfolder: false,
|
folderPreset: "none",
|
||||||
albumSubfolder: false,
|
folderTemplate: "",
|
||||||
|
filenamePreset: "title-artist",
|
||||||
|
filenameTemplate: "{title} - {artist}",
|
||||||
trackNumber: false,
|
trackNumber: false,
|
||||||
operatingSystem: detectOS()
|
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> {
|
async function fetchDefaultPath(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = await GetDefaults();
|
const data = await GetDefaults();
|
||||||
@@ -55,8 +142,46 @@ export function getSettings(): Settings {
|
|||||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||||
delete parsed.darkMode;
|
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)
|
// Always use detected OS (don't persist it)
|
||||||
parsed.operatingSystem = detectOS();
|
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 };
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,6 +190,34 @@ export function getSettings(): Settings {
|
|||||||
return DEFAULT_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> {
|
export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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[] = [
|
export const themes: Theme[] = [
|
||||||
{
|
createTheme("amber", "Amber", primaryColors.amber),
|
||||||
name: "neutral",
|
createTheme("blue", "Blue", primaryColors.blue),
|
||||||
label: "Default",
|
createTheme("cyan", "Cyan", primaryColors.cyan),
|
||||||
cssVars: {
|
createTheme("emerald", "Emerald", primaryColors.emerald),
|
||||||
light: {
|
createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia),
|
||||||
background: "oklch(1 0 0)",
|
createTheme("green", "Green", primaryColors.green),
|
||||||
foreground: "oklch(0.145 0 0)",
|
createTheme("indigo", "Indigo", primaryColors.indigo),
|
||||||
card: "oklch(1 0 0)",
|
createTheme("lime", "Lime", primaryColors.lime),
|
||||||
"card-foreground": "oklch(0.145 0 0)",
|
createTheme("neutral", "Neutral", primaryColors.neutral),
|
||||||
popover: "oklch(1 0 0)",
|
createTheme("orange", "Orange", primaryColors.orange),
|
||||||
"popover-foreground": "oklch(0.145 0 0)",
|
createTheme("pink", "Pink", primaryColors.pink),
|
||||||
primary: "oklch(0.205 0 0)",
|
createTheme("purple", "Purple", primaryColors.purple),
|
||||||
"primary-foreground": "oklch(0.985 0 0)",
|
createTheme("red", "Red", primaryColors.red),
|
||||||
secondary: "oklch(0.97 0 0)",
|
createTheme("rose", "Rose", primaryColors.rose),
|
||||||
"secondary-foreground": "oklch(0.205 0 0)",
|
createTheme("sky", "Sky", primaryColors.sky),
|
||||||
muted: "oklch(0.97 0 0)",
|
createTheme("teal", "Teal", primaryColors.teal),
|
||||||
"muted-foreground": "oklch(0.556 0 0)",
|
createTheme("violet", "Violet", primaryColors.violet),
|
||||||
accent: "oklch(0.97 0 0)",
|
createTheme("yellow", "Yellow", primaryColors.yellow),
|
||||||
"accent-foreground": "oklch(0.205 0 0)",
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
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)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function applyTheme(themeName: string) {
|
export function applyTheme(themeName: string) {
|
||||||
const theme = themes.find((t) => t.name === themeName) || themes[0];
|
const theme = themes.find((t) => t.name === themeName) || themes[0];
|
||||||
|
|||||||
@@ -1,31 +1,55 @@
|
|||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound } from './audio';
|
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
|
// Wrapper functions for toast with sound effects
|
||||||
export const toastWithSound = {
|
export const toastWithSound = {
|
||||||
success: (message: string, data?: any) => {
|
success: (message: string, data?: any) => {
|
||||||
playSuccessSound();
|
const msg = message.toLowerCase();
|
||||||
return toast.success(message, data);
|
logger.success(msg);
|
||||||
|
if (isSfxEnabled()) playSuccessSound();
|
||||||
|
return toast.success(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
error: (message: string, data?: any) => {
|
error: (message: string, data?: any) => {
|
||||||
playErrorSound();
|
const msg = message.toLowerCase();
|
||||||
return toast.error(message, data);
|
logger.error(msg);
|
||||||
|
if (isSfxEnabled()) playErrorSound();
|
||||||
|
return toast.error(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
warning: (message: string, data?: any) => {
|
warning: (message: string, data?: any) => {
|
||||||
playWarningSound();
|
const msg = message.toLowerCase();
|
||||||
return toast.warning(message, data);
|
logger.warning(msg);
|
||||||
|
if (isSfxEnabled()) playWarningSound();
|
||||||
|
return toast.warning(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
info: (message: string, data?: any) => {
|
info: (message: string, data?: any) => {
|
||||||
playInfoSound();
|
const msg = message.toLowerCase();
|
||||||
return toast.info(message, data);
|
logger.info(msg);
|
||||||
|
if (isSfxEnabled()) playInfoSound();
|
||||||
|
return toast.info(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Default toast without specific type
|
// Default toast without specific type
|
||||||
message: (message: string, data?: any) => {
|
message: (message: string, data?: any) => {
|
||||||
playInfoSound();
|
const msg = message.toLowerCase();
|
||||||
return toast(message, data);
|
logger.info(msg);
|
||||||
|
if (isSfxEnabled()) playInfoSound();
|
||||||
|
return toast(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"
|
||||||
import type { Settings } from "./settings";
|
import type { Settings } from "./settings";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
@@ -18,10 +19,10 @@ export function sanitizePath(input: string, os: string): string {
|
|||||||
|
|
||||||
export function joinPath(os: string, ...parts: string[]): string {
|
export function joinPath(os: string, ...parts: string[]): string {
|
||||||
const sep = os === "Windows" ? "\\" : "/";
|
const sep = os === "Windows" ? "\\" : "/";
|
||||||
|
|
||||||
const filtered = parts.filter(Boolean);
|
const filtered = parts.filter(Boolean);
|
||||||
if (filtered.length === 0) return "";
|
if (filtered.length === 0) return "";
|
||||||
|
|
||||||
const joined = filtered
|
const joined = filtered
|
||||||
.map((p, i) => {
|
.map((p, i) => {
|
||||||
// For first part, only remove trailing slashes (preserve leading slash for absolute paths)
|
// For first part, only remove trailing slashes (preserve leading slash for absolute paths)
|
||||||
@@ -33,7 +34,7 @@ export function joinPath(os: string, ...parts: string[]): string {
|
|||||||
})
|
})
|
||||||
.filter(Boolean) // Remove empty strings after trimming
|
.filter(Boolean) // Remove empty strings after trimming
|
||||||
.join(sep);
|
.join(sep);
|
||||||
|
|
||||||
return joined;
|
return joined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,4 +45,15 @@ export function buildOutputPath(settings: Settings, folder?: string) {
|
|||||||
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
||||||
|
|
||||||
return sanitized ? joinPath(os, base, sanitized) : base;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="bottom-left" />
|
<Toaster position="bottom-left" duration={1000} />
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
)
|
);
|
||||||
|
|||||||
+107
-1
@@ -1,14 +1,29 @@
|
|||||||
|
export interface ArtistSimple {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrackMetadata {
|
export interface TrackMetadata {
|
||||||
artists: string;
|
artists: string;
|
||||||
name: string;
|
name: string;
|
||||||
album_name: string;
|
album_name: string;
|
||||||
|
album_artist?: string;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
images: string;
|
images: string;
|
||||||
release_date: string;
|
release_date: string;
|
||||||
track_number: number;
|
track_number: number;
|
||||||
|
total_tracks?: number; // Total tracks in album
|
||||||
|
disc_number?: number;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
isrc: string;
|
isrc: string;
|
||||||
album_type?: 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 {
|
export interface TrackResponse {
|
||||||
@@ -97,11 +112,14 @@ export type SpotifyMetadataResponse =
|
|||||||
|
|
||||||
export interface DownloadRequest {
|
export interface DownloadRequest {
|
||||||
isrc: string;
|
isrc: string;
|
||||||
service: "deezer" | "tidal" | "qobuz";
|
service: "tidal" | "qobuz" | "amazon";
|
||||||
query?: string;
|
query?: string;
|
||||||
track_name?: string;
|
track_name?: string;
|
||||||
artist_name?: string;
|
artist_name?: string;
|
||||||
album_name?: string;
|
album_name?: string;
|
||||||
|
album_artist?: string;
|
||||||
|
release_date?: string;
|
||||||
|
cover_url?: string; // Spotify cover URL for embedding
|
||||||
api_url?: string;
|
api_url?: string;
|
||||||
output_dir?: string;
|
output_dir?: string;
|
||||||
audio_format?: string;
|
audio_format?: string;
|
||||||
@@ -109,6 +127,16 @@ export interface DownloadRequest {
|
|||||||
filename_format?: string;
|
filename_format?: string;
|
||||||
track_number?: boolean;
|
track_number?: boolean;
|
||||||
position?: number;
|
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 {
|
export interface DownloadResponse {
|
||||||
@@ -116,9 +144,87 @@ export interface DownloadResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
file?: string;
|
file?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
already_exists?: boolean;
|
||||||
|
item_id?: string; // Queue item ID for tracking
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
status: string;
|
status: string;
|
||||||
time: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,38 +3,44 @@ module spotiflac
|
|||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bogem/id3v2/v2 v2.1.4
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.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
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
github.com/icza/bitio v1.1.0 // indirect
|
||||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.4 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
github.com/leaanthony/u v1.1.1 // 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/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/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // 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/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // 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
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
golang.org/x/crypto v0.45.0 // indirect
|
||||||
golang.org/x/net v0.35.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
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-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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
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.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
||||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
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 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
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=
|
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.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 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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/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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
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.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
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 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
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 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
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=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
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.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
golang.org/x/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-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-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-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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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-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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ func main() {
|
|||||||
Title: "SpotiFLAC",
|
Title: "SpotiFLAC",
|
||||||
Width: 1024,
|
Width: 1024,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
|
MinWidth: 1024,
|
||||||
|
MinHeight: 600,
|
||||||
Frameless: true,
|
Frameless: true,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
|
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
DragAndDrop: &options.DragAndDrop{
|
||||||
|
EnableFileDrop: true,
|
||||||
|
DisableWebViewDrop: false,
|
||||||
|
CSSDropProperty: "--wails-drop-target",
|
||||||
|
CSSDropValue: "drop",
|
||||||
|
},
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
app,
|
app,
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-3
@@ -2,10 +2,11 @@
|
|||||||
"vogel.qqdl.site",
|
"vogel.qqdl.site",
|
||||||
"maus.qqdl.site",
|
"maus.qqdl.site",
|
||||||
"hund.qqdl.site",
|
"hund.qqdl.site",
|
||||||
"eu-maus.qqdl.site",
|
|
||||||
"eu-katze.qqdl.site",
|
|
||||||
"katze.qqdl.site",
|
"katze.qqdl.site",
|
||||||
"wolf.qqdl.site",
|
"wolf.qqdl.site",
|
||||||
"tidal.kinoplus.online",
|
"tidal.kinoplus.online",
|
||||||
"jakarta.monochrome.tf"
|
"tidal-api.binimum.org",
|
||||||
|
"tidal-api-2.binimum.org",
|
||||||
|
"dev-api.squid.wtf",
|
||||||
|
"triton.squid.wtf"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "5.7"
|
|
||||||
}
|
|
||||||
+3
-2
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
"name": "SpotiFLAC",
|
"name": "SpotiFLAC",
|
||||||
"outputfilename": "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:build": "pnpm run build",
|
||||||
"frontend:dev:watcher": "pnpm run dev",
|
"frontend:dev:watcher": "pnpm run dev",
|
||||||
"frontend:dev:serverUrl": "auto",
|
"frontend:dev:serverUrl": "auto",
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "5.8"
|
"productVersion": "6.8"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user