Compare commits

..

34 Commits

Author SHA1 Message Date
afkarxyz d7b0ca8b3c v7.0 2025-12-24 09:09:39 +07:00
afkarxyz 8e6a1196b5 v7.0 2025-12-24 08:55:23 +07:00
afkarxyz c150124273 v7.0 2025-12-24 08:50:43 +07:00
afkarxyz cb2a41d068 v6.9 2025-12-20 10:57:13 +07:00
afkarxyz 820a4a30ab v6.9 2025-12-20 10:43:34 +07:00
afkarxyz c9e49b4b95 v6.9 2025-12-20 10:36:39 +07:00
afkarxyz 0ba9443ef4 v6.9 2025-12-20 07:13:55 +07:00
afkarxyz 7f8c968d6a v6.9 2025-12-20 04:59:07 +07:00
afkarxyz 4fee88329b v6.9 2025-12-20 04:49:58 +07:00
afkarxyz 66c30de2db v6.9 2025-12-19 21:04:12 +07:00
afkarxyz 436feb7f7c v6.9 2025-12-19 16:50:43 +07:00
afkarxyz 7d0fde3acc v6.9 2025-12-19 13:29:28 +07:00
afkarxyz 939883c9cd v6.9 2025-12-19 13:26:19 +07:00
TheLittleDoctor 99f3d59ff1 Added a toggle to choose between using Artist property or AlbumArtist property for folder name (#169)
* Corrected function call to correctly download albums vs playlists

* Added setting to prefer AlbumArtist as folder name.
  - In practice, this prevents albums with multiple artists, featured artists, collaborations, or collections like soundtracks, from being split up
  - This is occasionally desirable behavior, so I added it as a toggle rather than a default behavior
2025-12-19 08:39:03 +07:00
Kyle Rector 965f044e0c Showing singular "song" if playlist or album only has one song (#168) 2025-12-19 08:38:50 +07:00
afkarxyz 6a3bd37eb6 Ko-fi 2025-12-17 04:57:12 +07:00
afkarxyz b85ed89af3 v6.8 2025-12-14 15:35:11 +07:00
afkarxyz 8e78a882a3 v6.8 2025-12-14 12:36:37 +07:00
afkarxyz 237ee777c3 v6.8 2025-12-14 12:22:08 +07:00
afkarxyz b44a9abdd6 v6.8 2025-12-14 07:47:18 +07:00
afkarxyz 6c3fb13b25 v6.8 2025-12-13 18:32:50 +07:00
afkarxyz 9398e496e8 v6.8 2025-12-13 18:31:11 +07:00
afkarxyz a8d6276d00 v6.8 2025-12-13 16:42:29 +07:00
afkarxyz 4d3aac4990 v6.8 2025-12-13 14:28:08 +07:00
afkarxyz 64d7f82e52 v6.8 2025-12-13 13:59:41 +07:00
afkarxyz 910420634c v6.8 2025-12-13 13:36:36 +07:00
afkarxyz 22742f1ddd v6.8 2025-12-13 13:32:09 +07:00
afkarxyz 5c1d6619b5 v6.8 2025-12-13 11:43:17 +07:00
Sepehr Aroofzade 76669f551e Add FLAC lyrics embedding with LRCLIB fallback (#151)
* Update lyrics code

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

* Remove extra blank line in backend/filename.go
2025-12-12 17:27:09 +07:00
afkarxyz 8f10094e40 v6.7 2025-12-08 19:33:43 +07:00
afkarxyz 2fb544d1f8 v6.6 2025-12-05 05:26:33 +07:00
afkarxyz d16eaa324a v6.6 2025-12-05 05:25:50 +07:00
81 changed files with 10960 additions and 4044 deletions
+14 -3
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: '1.25.4'
GO_VERSION: '1.25.5'
NODE_VERSION: '24'
jobs:
@@ -243,10 +243,21 @@ jobs:
run: |
upx --best --lzma build/bin/SpotiFLAC
- name: Cache appimagetool
id: cache-appimagetool
uses: actions/cache@v4
with:
path: appimagetool
key: appimagetool-x86_64-v1
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
- name: Make appimagetool executable
run: chmod +x appimagetool
- name: Create AppImage
run: |
+3
View File
@@ -56,6 +56,9 @@ temp/
*.bak
*.old
# Test files
test
# Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md
build.txt
+5 -15
View File
@@ -4,7 +4,7 @@
<div align="center">
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -16,22 +16,12 @@ Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no
## Screenshot
![Image](https://github.com/user-attachments/assets/7aff07fa-abaa-4f88-96eb-c8b6794d206e)
![Image](https://github.com/user-attachments/assets/afe01529-bcf0-4486-8792-62af26adafee)
## Lossless Audio Checker
A simple utility for verifying the authenticity of FLAC files.
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
#
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
## Other projects
## Other project
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz)
+379 -21
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
@@ -43,6 +44,9 @@ type DownloadRequest struct {
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
AlbumName string `json:"album_name,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"` // Spotify cover URL for embedding
ApiURL string `json:"api_url,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
@@ -51,9 +55,14 @@ type DownloadRequest struct {
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
@@ -116,6 +125,56 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
return string(jsonData), nil
}
// SpotifySearchRequest represents the request structure for searching Spotify
type SpotifySearchRequest struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
// SearchSpotify searches for tracks, albums, artists, and playlists on Spotify
func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, error) {
if req.Query == "" {
return nil, fmt.Errorf("search query is required")
}
if req.Limit <= 0 {
req.Limit = 10
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return backend.SearchSpotify(ctx, req.Query, req.Limit)
}
// SpotifySearchByTypeRequest represents the request for searching by specific type with offset
type SpotifySearchByTypeRequest struct {
Query string `json:"query"`
SearchType string `json:"search_type"` // track, album, artist, playlist
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// SearchSpotifyByType searches for a specific type with offset support for pagination
func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.SearchResult, error) {
if req.Query == "" {
return nil, fmt.Errorf("search query is required")
}
if req.SearchType == "" {
return nil, fmt.Errorf("search type is required")
}
if req.Limit <= 0 {
req.Limit = 50
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return backend.SearchSpotifyByType(ctx, req.Query, req.SearchType, req.Limit, req.Offset)
}
// DownloadTrack downloads a track by ISRC
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.ISRC == "" {
@@ -126,11 +185,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
if req.Service == "" {
req.Service = "deezer"
req.Service = "tidal"
}
if req.OutputDir == "" {
req.OutputDir = "."
} else {
// Only normalize path separators, don't sanitize user's existing folder names
req.OutputDir = backend.NormalizePath(req.OutputDir)
}
if req.AudioFormat == "" {
@@ -174,7 +236,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
// 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)
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
@@ -204,7 +266,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" {
// Use provided URL directly
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
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{
@@ -212,7 +274,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Error: "Spotify ID is required for Amazon Music",
}, fmt.Errorf("spotify ID is required for Amazon Music")
}
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
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":
@@ -220,7 +282,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" {
// Use provided URL directly with fallback to multiple APIs
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
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{
@@ -229,13 +291,13 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}, 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.UseAlbumTrackNumber, req.Duration)
filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
}
} else {
downloader := backend.NewTidalDownloader(req.ApiURL)
if req.ServiceURL != "" {
// Use provided URL directly with specific API
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
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{
@@ -244,28 +306,24 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}, 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.UseAlbumTrackNumber, req.Duration)
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)
}
}
case "qobuz":
downloader := backend.NewQobuzDownloader()
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
// Default to "6" (FLAC 16-bit) for Qobuz if not specified
quality := req.AudioFormat
if quality == "" {
quality = "6"
}
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
default: // deezer
downloader := backend.NewDeezerDownloader()
if req.ServiceURL != "" {
// Use provided URL directly
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
} else {
if req.SpotifyID == "" {
default:
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Deezer",
}, fmt.Errorf("spotify ID is required for Deezer")
}
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
}
Error: fmt.Sprintf("Unknown service: %s", req.Service),
}, fmt.Errorf("unknown service: %s", req.Service)
}
if err != nil {
@@ -296,6 +354,58 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
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"
@@ -443,11 +553,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
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"`
DiscNumber int `json:"disc_number"`
}
// DownloadLyrics downloads lyrics for a single track
@@ -464,11 +578,15 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
SpotifyID: req.SpotifyID,
TrackName: req.TrackName,
ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
UseAlbumTrackNumber: req.UseAlbumTrackNumber,
DiscNumber: req.DiscNumber,
}
resp, err := client.DownloadLyrics(backendReq)
@@ -482,6 +600,56 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
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"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
DiscNumber int `json:"disc_number"`
}
// 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,
AlbumName: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
DiscNumber: req.DiscNumber,
}
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 == "" {
@@ -501,3 +669,193 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string
return string(jsonData), nil
}
// IsFFmpegInstalled checks if ffmpeg is installed
func (a *App) IsFFmpegInstalled() (bool, error) {
return backend.IsFFmpegInstalled()
}
// IsFFprobeInstalled checks if ffprobe is installed
func (a *App) IsFFprobeInstalled() (bool, error) {
return backend.IsFFprobeInstalled()
}
// 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",
}
}
// 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"`
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless)
}
// 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,
Codec: req.Codec,
}
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
}
// GetFileSizes returns file sizes for a list of file paths
func (a *App) GetFileSizes(files []string) map[string]int64 {
return backend.GetFileSizes(files)
}
// ListDirectoryFiles lists files and folders in a directory
func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) {
if dirPath == "" {
return nil, fmt.Errorf("directory path is required")
}
return backend.ListDirectory(dirPath)
}
// ListAudioFilesInDir lists only audio files in a directory recursively
func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) {
if dirPath == "" {
return nil, fmt.Errorf("directory path is required")
}
return backend.ListAudioFiles(dirPath)
}
// ReadFileMetadata reads metadata from an audio file
func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) {
if filePath == "" {
return nil, fmt.Errorf("file path is required")
}
return backend.ReadAudioMetadata(filePath)
}
// PreviewRenameFiles generates a preview of rename operations
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
return backend.PreviewRename(files, format)
}
// RenameFilesByMetadata renames files based on their metadata
func (a *App) RenameFilesByMetadata(files []string, format string) []backend.RenameResult {
return backend.RenameFiles(files, format)
}
// ReadTextFile reads a text file and returns its content
func (a *App) ReadTextFile(filePath string) (string, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
return string(content), nil
}
// RenameFileTo renames a file to a new name (keeping same directory)
func (a *App) RenameFileTo(oldPath, newName string) error {
dir := filepath.Dir(oldPath)
ext := filepath.Ext(oldPath)
newPath := filepath.Join(dir, newName+ext)
return os.Rename(oldPath, newPath)
}
// ReadImageAsBase64 reads an image file and returns it as base64 data URL
func (a *App) ReadImageAsBase64(filePath string) (string, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
ext := strings.ToLower(filepath.Ext(filePath))
var mimeType string
switch ext {
case ".jpg", ".jpeg":
mimeType = "image/jpeg"
case ".png":
mimeType = "image/png"
case ".gif":
mimeType = "image/gif"
case ".webp":
mimeType = "image/webp"
default:
mimeType = "image/jpeg"
}
encoded := base64.StdEncoding.EncodeToString(content)
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
}
// CheckFileExistenceRequest represents a track to check for existence
type CheckFileExistenceRequest struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
}
// CheckFilesExistence checks if multiple files already exist in the output directory
// This is done in parallel for better performance
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []backend.FileExistenceResult {
// Convert to backend struct format
backendTracks := make([]struct {
ISRC string
TrackName string
ArtistName string
}, len(tracks))
for i, t := range tracks {
backendTracks[i] = struct {
ISRC string
TrackName string
ArtistName string
}{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
}
}
return backend.CheckFilesExistParallel(outputDir, backendTracks)
}
// SkipDownloadItem marks a download item as skipped (file already exists)
func (a *App) SkipDownloadItem(itemID, filePath string) {
backend.SkipDownloadItem(itemID, filePath)
}
+108 -8
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -141,9 +142,24 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
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.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
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"]
@@ -356,7 +372,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
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 {
@@ -366,7 +382,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
@@ -383,13 +399,49 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
return "", err
}
// File already has embedded metadata, just rename if needed
// Rename file based on Spotify metadata
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Extract year from release date
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
// 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)
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
// Handle disc number
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
} else {
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
}
// 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)
@@ -399,10 +451,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled
// 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)
@@ -416,17 +469,64 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
}
}
// 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 string, useAlbumTrackNumber bool) (string, error) {
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, useAlbumTrackNumber)
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
}
+8
View File
@@ -12,6 +12,7 @@ import (
// AnalysisResult contains the audio analysis data
type AnalysisResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
SampleRate uint32 `json:"sample_rate"`
Channels uint8 `json:"channels"`
BitsPerSample uint8 `json:"bits_per_sample"`
@@ -30,6 +31,12 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
// Get file size
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
// Parse FLAC file
f, err := flac.ParseFile(filepath)
if err != nil {
@@ -38,6 +45,7 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
// Extract basic audio properties from STREAMINFO block
+258
View File
@@ -0,0 +1,258 @@
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"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
DiscNumber int `json:"disc_number"`
}
// 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, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
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)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// 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 = NormalizePath(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, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
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
}
-380
View File
@@ -1,380 +0,0 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"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) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), spotifyURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL...")
resp, err := d.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) {
// Extract track ID from Deezer URL
// Format: https://www.deezer.com/track/3412534581
parts := strings.Split(deezerURL, "/track/")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid Deezer URL format")
}
// Get the track ID part and remove any query parameters
trackIDStr := strings.Split(parts[1], "?")[0]
trackIDStr = strings.TrimSpace(trackIDStr)
var trackID int64
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
if err != nil {
return 0, fmt.Errorf("failed to parse track ID: %w", err)
}
return trackID, nil
}
func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv")
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
resp, err := d.client.Get(url)
if err != nil {
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")
}
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 buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Build base filename based on format
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
// Use album track number if in album folder structure, otherwise use playlist position
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
return filename + ".flac"
}
func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Using Deezer URL: %s\n", deezerURL)
// Extract track ID from URL
trackID, err := d.GetTrackIDFromURL(deezerURL)
if err != nil {
return "", err
}
// Get track info by ID
track, err := d.GetTrackByID(trackID)
if err != nil {
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Artist.Name
if len(track.Contributors) > 0 {
var mainArtists []string
for _, contrib := range track.Contributors {
if contrib.Role == "Main" {
mainArtists = append(mainArtists, contrib.Name)
}
}
if len(mainArtists) > 0 {
artists = strings.Join(mainArtists, ", ")
}
}
}
if 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)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + filepath, nil
}
fmt.Println("Downloading FLAC file...")
if err := d.DownloadFile(downloadURL, filepath); err != nil {
return "", err
}
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...")
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackPos > 0 {
trackNumberToEmbed = track.TrackPos
} else {
trackNumberToEmbed = position
}
}
metadata := Metadata{
Title: 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!")
fmt.Println("✓ Downloaded successfully from Deezer")
return filepath, nil
}
func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Deezer URL from Spotify track ID
deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
+615
View File
@@ -0,0 +1,615 @@
package backend
import (
"archive/tar"
"archive/zip"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"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 = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
// 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
}
// GetFFprobePath returns the full path to the ffprobe executable in app directory
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(ffprobePath); err == nil {
return ffprobePath, nil
}
return "", fmt.Errorf("ffprobe not found in app directory")
}
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
// Verify it's executable
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, 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)
}
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled {
// Download both
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
return err
}
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
} else if !ffmpegInstalled {
// Only download ffmpeg
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
// Only download ffprobe
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
}
return nil
}
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
var encodedURL string
switch runtime.GOOS {
case "windows":
encodedURL = ffmpegWindowsURL
case "linux":
encodedURL = ffmpegLinuxURL
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)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
return nil
}
// downloadAndExtract downloads a file and extracts it
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
// 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: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download: 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 {
// Scale progress between progressStart and progressEnd
rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
}
}
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 based on file type
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir)
}
return extractZip(tmpFile.Name(), destDir)
}
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
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"
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
ffprobeName = "ffprobe.exe"
}
foundFFmpeg := false
foundFFprobe := false
for _, f := range r.File {
baseName := filepath.Base(f.Name)
if f.FileInfo().IsDir() {
continue
}
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
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)
}
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
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"
ffprobeName := "ffprobe"
foundFFmpeg := false
foundFFprobe := false
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
baseName := filepath.Base(header.Name)
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
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)
}
_, err = io.Copy(outFile, tarReader)
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
// 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" (ignored for ALAC)
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
}
// 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":
// Determine codec: ALAC (lossless) or AAC (lossy)
codec := req.Codec
if codec == "" {
codec = "aac" // Default to AAC for backward compatibility
}
if codec == "alac" {
// ALAC - Apple Lossless (no bitrate needed)
args = append(args,
"-codec:a", "alac",
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
)
} else {
// AAC - lossy with bitrate
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
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
// +build !windows
package backend
import (
"os/exec"
)
// setHideWindow is a no-op on non-Windows platforms
func setHideWindow(cmd *exec.Cmd) {
// No-op on Unix-like systems
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
// +build windows
package backend
import (
"os/exec"
"syscall"
)
// setHideWindow sets HideWindow attribute for Windows processes
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}
+52
View File
@@ -0,0 +1,52 @@
package backend
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// SelectMultipleFiles opens a file dialog to select multiple audio files
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
Pattern: "*.mp3;*.m4a;*.flac",
},
{
DisplayName: "MP3 Files (*.mp3)",
Pattern: "*.mp3",
},
{
DisplayName: "M4A Files (*.m4a)",
Pattern: "*.m4a",
},
{
DisplayName: "FLAC Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
if err != nil {
return nil, err
}
return files, nil
}
// SelectOutputDirectory opens a directory dialog to select output folder
func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory",
})
if err != nil {
return "", err
}
return dir, nil
}
+498
View File
@@ -0,0 +1,498 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
// FileInfo represents information about a file or folder
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Children []FileInfo `json:"children,omitempty"`
}
// AudioMetadata represents metadata read from an audio file
type AudioMetadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
Year string `json:"year"`
}
// RenamePreview represents a preview of file rename operation
type RenamePreview struct {
OldPath string `json:"old_path"`
OldName string `json:"old_name"`
NewName string `json:"new_name"`
NewPath string `json:"new_path"`
Error string `json:"error,omitempty"`
Metadata AudioMetadata `json:"metadata"`
}
// RenameResult represents the result of a rename operation
type RenameResult struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// ListDirectory lists files and folders in a directory
func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var result []FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
fileInfo := FileInfo{
Name: entry.Name(),
Path: filepath.Join(dirPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
}
// If it's a directory, recursively list its contents
if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path)
if err == nil {
fileInfo.Children = children
}
}
result = append(result, fileInfo)
}
return result, nil
}
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files with errors
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
result = append(result, FileInfo{
Name: info.Name(),
Path: path,
IsDir: false,
Size: info.Size(),
})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return result, nil
}
// ReadAudioMetadata reads metadata from an audio file
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
}
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return readFlacMetadata(filePath)
case ".mp3":
return readMp3Metadata(filePath)
case ".m4a":
return readM4aMetadata(filePath)
default:
return nil, fmt.Errorf("unsupported file format: %s", ext)
}
}
// readFlacMetadata reads metadata from a FLAC file
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
metadata := &AudioMetadata{}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) != 2 {
continue
}
fieldName := strings.ToUpper(parts[0])
value := parts[1]
switch fieldName {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
case "ALBUM":
metadata.Album = value
case "ALBUMARTIST":
metadata.AlbumArtist = value
case "TRACKNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.TrackNumber = num
}
case "DISCNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.DiscNumber = num
}
case "DATE", "YEAR":
metadata.Year = value
}
}
}
}
return metadata, nil
}
// readMp3Metadata reads metadata from an MP3 file
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
metadata := &AudioMetadata{
Title: tag.Title(),
Artist: tag.Artist(),
Album: tag.Album(),
Year: tag.Year(),
}
// Get Album Artist (TPE2)
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text
}
}
// Get Track Number
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
}
}
// Get Disc Number
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
}
}
return metadata, nil
}
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, err
}
// Use ffprobe to get metadata in JSON format (both format and stream tags)
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return nil, err
}
// Parse JSON output
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return nil, err
}
metadata := &AudioMetadata{}
// Merge tags from format and streams (format tags take priority)
allTags := make(map[string]string)
// First add stream tags
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
// Then add format tags (overwrite stream tags)
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
// Parse tags
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "track":
// Format might be "4" or "4/12"
trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
case "disc":
discStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
case "date", "year":
if metadata.Year == "" || len(value) > len(metadata.Year) {
metadata.Year = value
}
}
}
return metadata, nil
}
// readM4aMetadata reads metadata from an M4A file using ffprobe
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath)
if err != nil {
return &AudioMetadata{}, nil
}
return metadata, nil
}
// GenerateFilename generates a new filename based on metadata and format template
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil {
return ""
}
result := format
// Extract year (first 4 characters only)
year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
// Replace placeholders
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
// Track number with padding
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else {
result = strings.ReplaceAll(result, "{track}", "")
}
// Disc number
if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else {
result = strings.ReplaceAll(result, "{disc}", "")
}
// Clean up multiple spaces and trim
result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ")
// Remove leading/trailing separators
result = strings.Trim(result, " -._")
if result == "" {
return ""
}
return result + ext
}
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
func sanitizeFilenameForRename(name string) string {
// Remove characters that are invalid in filenames
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name
for _, char := range invalid {
result = strings.ReplaceAll(result, char, "")
}
return strings.TrimSpace(result)
}
// PreviewRename generates a preview of rename operations
func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview
for _, filePath := range files {
preview := RenamePreview{
OldPath: filePath,
OldName: filepath.Base(filePath),
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
preview.Error = err.Error()
previews = append(previews, preview)
continue
}
preview.Metadata = *metadata
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
preview.Error = "Could not generate filename (missing metadata)"
previews = append(previews, preview)
continue
}
preview.NewName = newName
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
previews = append(previews, preview)
}
return previews
}
// GetFileSizes returns file sizes for a list of file paths
func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64)
for _, filePath := range files {
info, err := os.Stat(filePath)
if err == nil {
result[filePath] = info.Size()
}
}
return result
}
// RenameFiles renames files based on their metadata
func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult
for _, filePath := range files {
result := RenameResult{
OldPath: filePath,
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
result.Error = "Could not generate filename (missing metadata)"
result.Success = false
results = append(results, result)
continue
}
newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath
// Check if new path already exists (and is different from old path)
if newPath != filePath {
if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists"
result.Success = false
results = append(results, result)
continue
}
}
// Rename the file
if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
result.Success = true
results = append(results, result)
}
return results
}
+150 -7
View File
@@ -2,19 +2,56 @@ 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 {
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
// Build base filename based on format
// 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)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// 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)
@@ -24,23 +61,129 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled
// Note: We can't determine the exact track number without fetching from API
// So we only add it if position > 0 (bulk download)
// 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 {
re := regexp.MustCompile(`[<>:"/\\|?*]`)
sanitized := re.ReplaceAllString(name, "_")
// 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
}
// NormalizePath only normalizes path separators without modifying folder names
// Use this for user-provided paths that already exist on the filesystem
func NormalizePath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
}
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
// Use this only for NEW folders being created (artist names, album names, etc.)
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)
}
+243 -20
View File
@@ -6,12 +6,27 @@ import (
"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"`
@@ -31,11 +46,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
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"`
DiscNumber int `json:"disc_number"`
}
// LyricsDownloadResponse represents the response from lyrics download
@@ -59,33 +78,200 @@ func NewLyricsClient() *LyricsClient {
}
}
// FetchLyrics fetches lyrics from the Spotify Lyrics API
func (c *LyricsClient) FetchLyrics(spotifyID string) (*LyricsResponse, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9zcG90aWZ5LWx5cmljcy1hcGktcGkudmVyY2VsLmFwcC8/dHJhY2tpZD0=")
url := fmt.Sprintf("%s%s", string(apiBase), spotifyID)
// 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(url)
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch lyrics: %v", err)
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 response: %v", err)
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
}
var lyricsResp LyricsResponse
if err := json.Unmarshal(body, &lyricsResp); err != nil {
return nil, fmt.Errorf("failed to parse lyrics 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)
}
if lyricsResp.Error {
return nil, fmt.Errorf("lyrics not found for this track")
// 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{},
}
return &lyricsResp, nil
// Prefer synced lyrics, fall back to plain
lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics
resp.SyncType = "UNSYNCED"
}
if lyricsText == "" {
resp.Error = true
return resp
}
// Parse synced lyrics format [mm:ss.xx] text
lines := strings.Split(lyricsText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Check if line has timestamp [mm:ss.xx]
if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]")
if closeBracket > 0 {
timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:])
// Convert [mm:ss.xx] to milliseconds
ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
Words: words,
})
continue
}
}
// Plain lyrics line (no timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "0",
Words: line,
})
}
return resp
}
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
func lrcTimestampToMs(timestamp string) int64 {
var minutes, seconds, centiseconds int64
// Try parsing mm:ss.xx format
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return 0
}
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read failed: %v", err)
}
var results []LRCLibResponse
if err := json.Unmarshal(body, &results); err != nil {
return nil, fmt.Errorf("parse failed: %v", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("no results found")
}
// Find best match - prefer one with synced lyrics
var best *LRCLibResponse
for i := range results {
if results[i].SyncedLyrics != "" {
best = &results[i]
break
}
if best == nil && results[i].PlainLyrics != "" {
best = &results[i]
}
}
if best == nil {
best = &results[0]
}
return c.convertLRCLibToLyricsResponse(best), nil
}
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
func simplifyTrackName(name string) string {
// Remove content in parentheses
if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
// Remove content after " - " (like "From the Motion Picture")
if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
return name
}
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
// 1. Try LRCLIB exact match
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil
}
fmt.Printf(" LRCLIB exact: %v\n", err)
// 2. Try LRCLIB search
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search", nil
}
fmt.Printf(" LRCLIB search: %v\n", err)
// 3. Try with simplified track name (remove parentheses, subtitles)
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil
}
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search (simplified)", nil
}
}
return nil, "", fmt.Errorf("lyrics not found in any source")
}
// ConvertToLRC converts lyrics response to LRC format
@@ -126,13 +312,47 @@ func msToLRCTimestamp(msStr string) string {
}
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
// Build base filename based on format
// 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)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// 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)
@@ -142,10 +362,11 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
}
return filename + ".lrc"
}
@@ -163,6 +384,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -177,7 +400,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
if filenameFormat == "" {
filenameFormat = "title-artist" // default
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
@@ -190,8 +413,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, nil
}
// Fetch lyrics
lyrics, err := c.FetchLyrics(req.SpotifyID)
// Fetch lyrics from LRCLIB
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if err != nil {
return &LyricsDownloadResponse{
Success: false,
+498 -1
View File
@@ -3,9 +3,13 @@ package backend
import (
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
"sync"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
@@ -15,10 +19,15 @@ type Metadata struct {
Title string
Artist string
Album string
Date string
AlbumArtist string
Date string // Recorded date (full date YYYY-MM-DD)
ReleaseDate string // Release date (full date) - kept for compatibility
TrackNumber int
TotalTracks int // Total tracks in album
DiscNumber int
ISRC string
Lyrics string
Description string
}
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
@@ -46,18 +55,31 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.Album != "" {
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
}
if metadata.AlbumArtist != "" {
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
}
if metadata.Date != "" {
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
}
if metadata.TrackNumber > 0 {
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
}
if metadata.TotalTracks > 0 {
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
}
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
// Lyrics is added last to keep it at the bottom
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced
}
cmtBlock := cmt.Marshal()
if cmtIdx < 0 {
@@ -113,6 +135,75 @@ func fileExists(path string) bool {
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) {
@@ -187,3 +278,409 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
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)
}
}
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct {
ISRC string `json:"isrc"`
Exists bool `json:"exists"`
FilePath string `json:"file_path,omitempty"`
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
}
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
func CheckFilesExistParallel(outputDir string, tracks []struct {
ISRC string
TrackName string
ArtistName string
}) []FileExistenceResult {
results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIndex := buildISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
go func(idx int, t struct {
ISRC string
TrackName string
ArtistName string
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
}
if t.ISRC != "" {
if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists {
result.Exists = true
result.FilePath = filePath
}
}
results[idx] = result
}(i, track)
}
wg.Wait()
return results
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) map[string]string {
index := make(map[string]string)
// Walk directory recursively - only check .flac files for SpotiFLAC
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(path))
if ext != ".flac" {
return nil
}
// Read ISRC from file
isrc, err := ReadISRCFromFile(path)
if err != nil || isrc == "" {
return nil
}
// Store in index (uppercase for case-insensitive matching)
index[strings.ToUpper(isrc)] = path
return nil
})
return index
}
+7
View File
@@ -264,6 +264,13 @@ func UpdateItemProgress(id string, progress, speed float64) {
}
}
// 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()
+117 -60
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -91,8 +93,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
var searchResp QobuzSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &searchResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(searchResp.Tracks.Items) == 0 {
@@ -105,15 +122,20 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
// Map quality to Qobuz quality code
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
qualityCode := "27" // Default to Hi-Res
qualityCode := quality // Use the provided quality parameter
if qualityCode == "" {
qualityCode = "6" // Default to FLAC 16-bit if not specified
}
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
resp, err := q.client.Get(primaryURL)
if err == nil && resp.StatusCode == 200 {
@@ -149,12 +171,25 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("API returned empty response")
}
fmt.Printf("Fallback API response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if streamResp.URL == "" {
@@ -167,7 +202,13 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
resp, err := q.client.Get(url)
// Use a separate client with a longer timeout. The default client's 60s limit
// causes downloads to fail on slow connections or for large Hi-Res files.
downloadClient := &http.Client{
Timeout: 5 * time.Minute, // 5 minutes for large files
}
resp, err := downloadClient.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -222,10 +263,48 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err
}
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Build base filename based on format
// Determine track number to use
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
// Check if format is a template (contains {})
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist)
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// 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)
@@ -235,20 +314,16 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
// Use album track number if in album folder structure, otherwise use playlist position
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
}
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
// Create output directory if it doesn't exist
@@ -263,29 +338,11 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
// All metadata from Spotify - no fallback to Qobuz
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Performer.Name
if track.Album.Artist.Name != "" {
artists = track.Album.Artist.Name
}
}
if trackTitle == "" {
trackTitle = track.Title
if track.Version != "" && track.Version != "null" {
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
}
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
@@ -314,15 +371,17 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
// Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
// Build filename based on format settings (use Spotify track number)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
@@ -338,41 +397,39 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.Image.Large != "" {
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
if spotifyCoverURL != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
fmt.Println("Embedding metadata and cover art...")
releaseYear := ""
if len(track.ReleaseDateOriginal) >= 4 {
releaseYear = track.ReleaseDateOriginal[:4]
}
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackNumber > 0 {
trackNumberToEmbed = track.TrackNumber
} else {
trackNumberToEmbed = position
}
// Determine track number to embed - ALL from Spotify
trackNumberToEmbed := spotifyTrackNumber
if position > 0 && !useAlbumTrackNumber {
trackNumberToEmbed = position // Use playlist position
}
// ALL metadata from Spotify
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: releaseYear,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
TrackNumber: trackNumberToEmbed,
DiscNumber: track.MediaNumber,
ISRC: track.ISRC,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
ISRC: isrc, // ISRC from Spotify (passed as parameter)
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
+36 -20
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
@@ -18,7 +19,6 @@ type SongLinkClient struct {
type SongLinkURLs struct {
TidalURL string `json:"tidal_url"`
DeezerURL string `json:"deezer_url"`
AmazonURL string `json:"amazon_url"`
}
@@ -26,11 +26,9 @@ type SongLinkURLs struct {
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Deezer bool `json:"deezer"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
TidalURL string `json:"tidal_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
}
@@ -126,8 +124,23 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
// 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{}
@@ -138,12 +151,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
fmt.Printf("✓ Tidal URL found\n")
}
// Extract Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
urls.DeezerURL = deezerLink.URL
fmt.Printf("✓ Deezer URL found\n")
}
// Extract Amazon URL
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
@@ -155,7 +162,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
// Check if at least one URL was found
if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" {
if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
@@ -245,8 +252,23 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
// 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{
@@ -259,12 +281,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.TidalURL = tidalLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
+374 -205
View File
@@ -2,11 +2,7 @@ package backend
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -14,8 +10,6 @@ import (
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -23,13 +17,12 @@ import (
)
const (
spotifyTokenURL = "https://open.spotify.com/api/token"
spotifyTokenURL = "https://accounts.spotify.com/api/token"
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
secretBytesRemotePath = "https://cdn.jsdelivr.net/gh/afkarxyz/secretBytes@refs/heads/main/secrets/secretBytes.json"
)
var (
@@ -39,16 +32,35 @@ var (
// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API.
type SpotifyMetadataClient struct {
httpClient *http.Client
clientID string
clientSecret string
cachedToken string
tokenExpiresAt time.Time
rng *rand.Rand
rngMu sync.Mutex
userAgent string
}
// NewSpotifyMetadataClient creates a ready-to-use client with sane defaults.
// NewSpotifyMetadataClient creates a ready-to-use client with Official Spotify API credentials.
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
// Decode client ID from base64
clientID := ""
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
clientID = string(decoded)
}
// Decode client secret from base64
clientSecret := ""
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
clientSecret = string(decoded)
}
c := &SpotifyMetadataClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
}
c.userAgent = c.randomUserAgent()
@@ -57,16 +69,19 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
// TrackMetadata mirrors the filtered track payload returned by the Python script.
type TrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
SpotifyID string `json:"spotify_id,omitempty"`
}
// ArtistSimple holds basic artist info for clickable artists
@@ -78,17 +93,20 @@ type ArtistSimple struct {
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
type AlbumTrackMetadata struct {
SpotifyID string `json:"spotify_id,omitempty"`
Artists string `json:"artists"`
Name string `json:"name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
DurationMS int `json:"duration_ms"`
Images string `json:"images"`
ReleaseDate string `json:"release_date"`
TrackNumber int `json:"track_number"`
TotalTracks int `json:"total_tracks,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumType string `json:"album_type,omitempty"`
SpotifyID string `json:"spotify_id,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
@@ -181,17 +199,10 @@ type spotifyURI struct {
DiscographyGroup string
}
type secretEntry struct {
Version int `json:"version"`
Secret []int `json:"secret"`
}
type serverTimeResponse struct {
ServerTime int64 `json:"serverTime"`
}
type accessTokenResponse struct {
AccessToken string `json:"accessToken"`
AccessToken string `json:"access_token"`
ExpiresIn interface{} `json:"expires_in"` // Can be number or string
TokenType string `json:"token_type"`
}
type image struct {
@@ -227,6 +238,7 @@ type trackSimplified struct {
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
}
@@ -236,6 +248,7 @@ type trackFull struct {
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ExternalURL externalURL `json:"external_urls"`
ExternalID externalID `json:"external_ids"`
Album albumSimplified `json:"album"`
@@ -344,7 +357,9 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
case "artist_discography":
return c.fetchArtistDiscography(ctx, parsed, token, batch, delay)
case "artist":
return c.fetchArtist(ctx, parsed.ID, token)
// Automatically fetch discography for artist URLs to get full data (albums + tracks)
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
return c.fetchArtistDiscography(ctx, discographyParsed, token, batch, delay)
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
}
@@ -502,16 +517,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
})
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.Track.ID,
Artists: joinArtists(item.Track.Artists),
Name: item.Track.Name,
AlbumName: item.Track.Album.Name,
AlbumArtist: joinArtists(item.Track.Album.Artists),
DurationMS: item.Track.DurationMS,
Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images),
ReleaseDate: item.Track.Album.ReleaseDate,
TrackNumber: item.Track.TrackNumber,
TotalTracks: item.Track.Album.TotalTracks,
DiscNumber: item.Track.DiscNumber,
ExternalURL: item.Track.ExternalURL.Spotify,
ISRC: item.Track.ExternalID.ISRC,
SpotifyID: item.Track.ID,
AlbumID: item.Track.Album.ID,
AlbumURL: item.Track.Album.ExternalURL.Spotify,
ArtistID: artistID,
@@ -551,16 +569,19 @@ func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumR
for _, item := range raw.Data.Tracks.Items {
isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache)
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.ID,
Artists: joinArtists(item.Artists),
Name: item.Name,
AlbumName: raw.Data.Name,
AlbumArtist: joinArtists(raw.Data.Artists),
DurationMS: item.DurationMS,
Images: albumImage,
ReleaseDate: raw.Data.ReleaseDate,
TrackNumber: item.TrackNumber,
TotalTracks: raw.Data.TotalTracks,
DiscNumber: item.DiscNumber,
ExternalURL: item.ExternalURL.Spotify,
ISRC: isrc,
SpotifyID: item.ID,
})
}
@@ -629,17 +650,20 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
})
}
allTracks = append(allTracks, AlbumTrackMetadata{
SpotifyID: tr.ID,
Artists: joinArtists(tr.Artists),
Name: tr.Name,
AlbumName: alb.Name,
AlbumArtist: joinArtists(alb.Artists),
AlbumType: alb.AlbumType,
DurationMS: tr.DurationMS,
Images: albumImage,
ReleaseDate: alb.ReleaseDate,
TrackNumber: tr.TrackNumber,
TotalTracks: alb.TotalTracks,
DiscNumber: tr.DiscNumber,
ExternalURL: tr.ExternalURL.Spotify,
ISRC: isrc,
SpotifyID: tr.ID,
AlbumID: alb.ID,
AlbumURL: alb.ExternalURL.Spotify,
ArtistID: artistID,
@@ -676,16 +700,19 @@ func formatTrackData(raw *trackFull) TrackResponse {
}
return TrackResponse{
Track: TrackMetadata{
SpotifyID: raw.ID,
Artists: joinArtists(raw.Artists),
Name: raw.Name,
AlbumName: raw.Album.Name,
AlbumArtist: joinArtists(raw.Album.Artists),
DurationMS: raw.DurationMS,
Images: firstImageURL(raw.Album.Images),
ReleaseDate: raw.Album.ReleaseDate,
TrackNumber: raw.TrackNumber,
TotalTracks: raw.Album.TotalTracks,
DiscNumber: raw.DiscNumber,
ExternalURL: raw.ExternalURL.Spotify,
ISRC: raw.ExternalID.ISRC,
SpotifyID: raw.ID,
},
}
}
@@ -839,211 +866,58 @@ func (c *SpotifyMetadataClient) randRange(min, max int) int {
}
func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) {
code, serverTime, version, err := c.generateTOTP(ctx)
// Return cached token if still valid
if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) {
return c.cachedToken, nil
}
// Prepare request body for Client Credentials Flow
data := url.Values{}
data.Set("grant_type", "client_credentials")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
timestampMS := time.Now().UnixMilli()
params := url.Values{}
params.Set("reason", "init")
params.Set("productType", "web-player")
params.Set("totp", code)
params.Set("totpServerTime", strconv.FormatInt(serverTime, 10))
params.Set("totpVer", strconv.Itoa(version))
params.Set("sTime", strconv.FormatInt(serverTime, 10))
params.Set("cTime", strconv.FormatInt(timestampMS, 10))
params.Set("buildVer", "web-player_2025-07-02_1720000000000_12345678")
params.Set("buildDate", "2025-07-02")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotifyTokenURL, nil)
if err != nil {
return "", err
}
req.URL.RawQuery = params.Encode()
req.Header = c.baseHeaders()
// Set Basic Auth header
req.SetBasicAuth(c.clientID, c.clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get access token. Status code: %d", resp.StatusCode)
return "", fmt.Errorf("failed to get access token. Status code: %d, Response: %s", resp.StatusCode, string(body))
}
var token accessTokenResponse
if err := json.Unmarshal(body, &token); err != nil {
return "", err
}
if token.AccessToken == "" {
return "", errors.New("failed to get access token: empty token received")
}
// Cache the token
c.cachedToken = token.AccessToken
// Official API returns expires_in in seconds
if expiresIn, ok := token.ExpiresIn.(float64); ok {
c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) // Refresh 60 seconds before expiry
}
return token.AccessToken, nil
}
func (c *SpotifyMetadataClient) generateTOTP(ctx context.Context) (string, int64, int, error) {
secrets, _, err := c.fetchSecretBytes(ctx)
if err != nil {
return "", 0, 0, err
}
if len(secrets) == 0 {
return "", 0, 0, errors.New("no secrets available")
}
latest := secrets[0]
for _, entry := range secrets[1:] {
if entry.Version > latest.Version {
latest = entry
}
}
builder := strings.Builder{}
for idx, val := range latest.Secret {
processed := val ^ ((idx % 33) + 9)
builder.WriteString(strconv.Itoa(processed))
}
utfBytes := []byte(builder.String())
hexStr := hex.EncodeToString(utfBytes)
secretBytes, err := hex.DecodeString(hexStr)
if err != nil {
return "", 0, 0, err
}
b32Secret := base32.StdEncoding.EncodeToString(secretBytes)
serverTime, err := c.fetchServerTime(ctx)
if err != nil {
return "", 0, 0, err
}
code, err := computeTOTP(b32Secret, serverTime)
if err != nil {
return "", 0, 0, err
}
return code, serverTime, latest.Version, nil
}
func (c *SpotifyMetadataClient) fetchSecretBytes(ctx context.Context) ([]secretEntry, bool, error) {
// Add cache busting parameter with current timestamp
urlWithCacheBust := fmt.Sprintf("%s?t=%d", secretBytesRemotePath, time.Now().Unix())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlWithCacheBust, nil)
if err == nil {
// Add headers to bypass cache
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Expires", "0")
resp, err := c.httpClient.Do(req)
if err == nil {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil && resp.StatusCode == http.StatusOK {
var secrets []secretEntry
if jsonErr := json.Unmarshal(body, &secrets); jsonErr == nil {
return secrets, false, nil
}
}
}
}
home, err := os.UserHomeDir()
if err != nil {
return nil, false, fmt.Errorf("GitHub fetch failed and could not resolve home directory: %w", err)
}
localPath := filepath.Join(home, ".spotify-secret", "secretBytes.json")
data, err := os.ReadFile(localPath)
if err != nil {
return nil, false, fmt.Errorf("failed to fetch secrets from both GitHub and local: %w", err)
}
var secrets []secretEntry
if err := json.Unmarshal(data, &secrets); err != nil {
return nil, false, fmt.Errorf("failed to process local secrets: %w", err)
}
return secrets, true, nil
}
func (c *SpotifyMetadataClient) fetchServerTime(ctx context.Context) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.spotify.com/api/server-time", nil)
if err != nil {
return 0, err
}
req.Header = c.serverTimeHeaders()
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return 0, err
}
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to get server time. Status code: %d", resp.StatusCode)
}
var payload serverTimeResponse
if err := json.Unmarshal(body, &payload); err != nil {
return 0, err
}
if payload.ServerTime == 0 {
return 0, errors.New("failed to fetch server time from Spotify")
}
return payload.ServerTime, nil
}
func (c *SpotifyMetadataClient) serverTimeHeaders() http.Header {
h := http.Header{}
h.Set("Host", "open.spotify.com")
h.Set("User-Agent", c.randomUserAgent())
h.Set("Accept", "*/*")
return h
}
func computeTOTP(b32Secret string, timestamp int64) (string, error) {
normalized := strings.ToUpper(strings.ReplaceAll(b32Secret, " ", ""))
key, err := base32.StdEncoding.DecodeString(normalized)
if err != nil {
return "", err
}
// Normalise milliseconds if necessary.
if timestamp > 1_000_000_000_000 {
timestamp /= 1000
}
counter := uint64(timestamp / 30)
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, key)
if _, err := mac.Write(buf[:]); err != nil {
return "", err
}
sum := mac.Sum(nil)
if len(sum) < 20 {
return "", errors.New("unexpected hmac length for TOTP")
}
offset := sum[len(sum)-1] & 0x0f
binaryCode := (int(sum[offset])&0x7f)<<24 |
(int(sum[offset+1])&0xff)<<16 |
(int(sum[offset+2])&0xff)<<8 |
(int(sum[offset+3]) & 0xff)
otp := binaryCode % 1_000_000
return fmt.Sprintf("%06d", otp), nil
}
func parseSpotifyURI(input string) (spotifyURI, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
@@ -1219,3 +1093,298 @@ func maxInt(a, b int) int {
}
return b
}
// SearchResult represents a single search result item
type SearchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // track, album, artist, playlist
Artists string `json:"artists,omitempty"`
AlbumName string `json:"album_name,omitempty"`
Images string `json:"images"`
ReleaseDate string `json:"release_date,omitempty"`
ExternalURL string `json:"external_urls"`
Duration int `json:"duration_ms,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
Owner string `json:"owner,omitempty"` // for playlists
}
// SearchResponse contains search results grouped by type
type SearchResponse struct {
Tracks []SearchResult `json:"tracks"`
Albums []SearchResult `json:"albums"`
Artists []SearchResult `json:"artists"`
Playlists []SearchResult `json:"playlists"`
}
// Spotify API search response structures
type searchTracksResponse struct {
Tracks struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
Album struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
ReleaseDate string `json:"release_date"`
ExternalURL externalURL `json:"external_urls"`
} `json:"album"`
} `json:"items"`
} `json:"tracks"`
}
type searchAlbumsResponse struct {
Albums struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
AlbumType string `json:"album_type"`
TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"`
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
} `json:"items"`
} `json:"albums"`
}
type searchArtistsResponse struct {
Artists struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Followers struct {
Total int `json:"total"`
} `json:"followers"`
} `json:"items"`
} `json:"artists"`
}
type searchPlaylistsResponse struct {
Playlists struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Owner struct {
DisplayName string `json:"display_name"`
} `json:"owner"`
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
} `json:"items"`
} `json:"playlists"`
}
// Search performs a search on Spotify and returns results for tracks, albums, artists, and playlists
func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) {
if query == "" {
return nil, errors.New("search query cannot be empty")
}
if limit <= 0 || limit > 50 {
limit = 50
}
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
// URL encode the query
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=track,album,artist,playlist&limit=%d", encodedQuery, limit)
response := &SearchResponse{
Tracks: make([]SearchResult, 0),
Albums: make([]SearchResult, 0),
Artists: make([]SearchResult, 0),
Playlists: make([]SearchResult, 0),
}
// Fetch tracks
var tracksResp searchTracksResponse
if err := c.getJSON(ctx, searchURL, token, &tracksResp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range tracksResp.Tracks.Items {
response.Tracks = append(response.Tracks, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "track",
Artists: joinArtists(item.Artists),
AlbumName: item.Album.Name,
Images: firstImageURL(item.Album.Images),
ReleaseDate: item.Album.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
Duration: item.DurationMS,
})
}
// Fetch albums
var albumsResp searchAlbumsResponse
if err := c.getJSON(ctx, searchURL, token, &albumsResp); err == nil {
for _, item := range albumsResp.Albums.Items {
response.Albums = append(response.Albums, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "album",
Artists: joinArtists(item.Artists),
Images: firstImageURL(item.Images),
ReleaseDate: item.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
TotalTracks: item.TotalTracks,
})
}
}
// Fetch artists
var artistsResp searchArtistsResponse
if err := c.getJSON(ctx, searchURL, token, &artistsResp); err == nil {
for _, item := range artistsResp.Artists.Items {
response.Artists = append(response.Artists, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "artist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
})
}
}
// Fetch playlists
var playlistsResp searchPlaylistsResponse
if err := c.getJSON(ctx, searchURL, token, &playlistsResp); err == nil {
for _, item := range playlistsResp.Playlists.Items {
response.Playlists = append(response.Playlists, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "playlist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
Owner: item.Owner.DisplayName,
TotalTracks: item.Tracks.Total,
})
}
}
return response, nil
}
// SearchSpotify is a convenience wrapper for the Search method
func SearchSpotify(ctx context.Context, query string, limit int) (*SearchResponse, error) {
client := NewSpotifyMetadataClient()
return client.Search(ctx, query, limit)
}
// SearchByType searches for a specific type (track, album, artist, playlist) with offset support
func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
if query == "" {
return nil, errors.New("search query cannot be empty")
}
if limit <= 0 || limit > 50 {
limit = 50
}
if offset < 0 || offset > 1000 {
offset = 0
}
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=%s&limit=%d&offset=%d", encodedQuery, searchType, limit, offset)
results := make([]SearchResult, 0)
switch searchType {
case "track":
var resp searchTracksResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Tracks.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "track",
Artists: joinArtists(item.Artists),
AlbumName: item.Album.Name,
Images: firstImageURL(item.Album.Images),
ReleaseDate: item.Album.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
Duration: item.DurationMS,
})
}
case "album":
var resp searchAlbumsResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Albums.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "album",
Artists: joinArtists(item.Artists),
Images: firstImageURL(item.Images),
ReleaseDate: item.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
TotalTracks: item.TotalTracks,
})
}
case "artist":
var resp searchArtistsResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Artists.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "artist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
})
}
case "playlist":
var resp searchPlaylistsResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Playlists.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "playlist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
Owner: item.Owner.DisplayName,
TotalTracks: item.Tracks.Total,
})
}
default:
return nil, fmt.Errorf("invalid search type: %s", searchType)
}
return results, nil
}
// SearchSpotifyByType is a convenience wrapper for SearchByType
func SearchSpotifyByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
client := NewSpotifyMetadataClient()
return client.SearchByType(ctx, query, searchType, limit, offset)
}
+548 -319
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -18,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<title>SpotiFLAC</title>
</head>
<body>
+17 -16
View File
@@ -17,37 +17,38 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17"
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.2.4"
"typescript-eslint": "^8.50.1",
"vite": "^7.3.0"
}
}
+1 -1
View File
@@ -1 +1 @@
23a60910537eca7800052fa01bf45b7a
0f9764c2a4597a75120d3e76c32af7a9
+880 -957
View File
File diff suppressed because it is too large Load Diff
+165 -50
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -9,15 +9,16 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Search, X } from "lucide-react";
import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, applyThemeMode } from "@/lib/settings";
import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
// Components
import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { SearchBar } from "@/components/SearchBar";
import { TrackInfo } from "@/components/TrackInfo";
@@ -27,43 +28,61 @@ import { ArtistInfo } from "@/components/ArtistInfo";
import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { AudioConverterPage } from "@/components/AudioConverterPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import type { HistoryItem } from "@/components/FetchHistory";
// Hooks
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5;
type PageType = "main" | "audio-analysis";
function App() {
const [currentPageView, setCurrentPageView] = useState<PageType>("main");
const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<string>("default");
const [currentPage, setCurrentPage] = useState(1);
const [currentListPage, setCurrentListPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.5";
const CURRENT_VERSION = "7.0";
const download = useDownload();
const metadata = useMetadata();
const lyrics = useLyrics();
const cover = useCover();
const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog();
useEffect(() => {
const initSettings = async () => {
const settings = getSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
applyFont(settings.fontFamily);
// Initialize default download path if not set
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
saveSettings(settingsWithDefaults);
}
};
initSettings();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
@@ -78,19 +97,31 @@ function App() {
checkForUpdates();
loadHistory();
// Scroll listener for jump to top button
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => {
mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("scroll", handleScroll);
};
}, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
useEffect(() => {
setSelectedTracks([]);
setSearchQuery("");
download.resetDownloadedTracks();
lyrics.resetLyricsState();
cover.resetCoverState();
availability.clearAvailability();
setSortBy("default");
setCurrentPage(1);
setCurrentListPage(1);
}, [metadata.metadata]);
const checkForUpdates = async () => {
@@ -99,9 +130,12 @@ function App() {
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
);
const data = await response.json();
// tag_name format: "v6.1" -> extract "6.1"
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
}
@@ -166,7 +200,6 @@ function App() {
}
};
// Add to history when metadata is successfully fetched
useEffect(() => {
if (!metadata.metadata || !spotifyUrl) return;
@@ -217,7 +250,7 @@ function App() {
const handleSearchChange = (value: string) => {
setSearchQuery(value);
setCurrentPage(1);
setCurrentListPage(1);
};
const toggleTrackSelection = (isrc: string) => {
@@ -250,6 +283,7 @@ function App() {
}
};
const renderMetadata = () => {
if (!metadata.metadata) return null;
@@ -269,9 +303,18 @@ function App() {
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
checkingAvailability={availability.checkingTrackId === track.spotify_id}
availability={availability.getAvailability(track.spotify_id || "")}
downloadingCover={cover.downloadingCover}
downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")}
failedCover={cover.failedCovers.has(track.spotify_id || "")}
skippedCover={cover.skippedCovers.has(track.spotify_id || "")}
onDownload={download.handleDownloadTrack}
onDownloadLyrics={lyrics.handleDownloadLyrics}
onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)
}
onOpenFolder={handleOpenFolder}
/>
);
@@ -294,7 +337,7 @@ function App() {
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
@@ -302,22 +345,33 @@ function App() {
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position)
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage}
onPageChange={setCurrentListPage}
onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
@@ -351,7 +405,7 @@ function App() {
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
@@ -359,15 +413,26 @@ function App() {
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position)
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
onDownloadSelected={() =>
download.handleDownloadSelected(
@@ -378,7 +443,7 @@ function App() {
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage}
onPageChange={setCurrentListPage}
onAlbumClick={metadata.handleAlbumClick}
onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
@@ -414,7 +479,7 @@ function App() {
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
@@ -422,18 +487,29 @@ function App() {
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position)
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)
}
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
@@ -444,7 +520,7 @@ function App() {
setSpotifyUrl(artistUrl);
}
}}
onPageChange={setCurrentPage}
onPageChange={setCurrentListPage}
onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
@@ -458,29 +534,26 @@ function App() {
return null;
};
const renderPage = () => {
switch (currentPage) {
case "settings":
return <SettingsPage />;
case "debug":
return <DebugLoggerPage />;
case "audio-analysis":
return <AudioAnalysisPage />;
case "audio-converter":
return <AudioConverterPage />;
case "file-manager":
return <FileManagerPage />;
default:
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">
{currentPageView === "audio-analysis" ? (
<AudioAnalysisPage onBack={() => setCurrentPageView("main")} />
) : (
<>
<Header
version={CURRENT_VERSION}
hasUpdate={hasUpdate}
onOpenAudioAnalysis={() => setCurrentPageView("audio-analysis")}
/>
{/* Download Progress Toast - Bottom Left */}
<DownloadProgressToast onClick={downloadQueue.openQueue} />
{/* Download Queue Dialog */}
<DownloadQueue
isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue}
releaseDate={releaseDate}
/>
{/* Timeout Dialog */}
@@ -585,18 +658,60 @@ function App() {
loading={metadata.loading}
onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata}
onFetchUrl={async (url) => {
setSpotifyUrl(url);
const updatedUrl = await metadata.handleFetchMetadata(url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
}}
history={fetchHistory}
onHistorySelect={handleHistorySelect}
onHistoryRemove={removeFromHistory}
hasResult={!!metadata.metadata}
searchMode={isSearchMode}
onSearchModeChange={setIsSearchMode}
/>
{metadata.metadata && renderMetadata()}
{!isSearchMode && metadata.metadata && renderMetadata()}
</>
);
}
};
return (
<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col">
<TitleBar />
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
{/* Main content area with sidebar offset */}
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{renderPage()}
</div>
</div>
{/* Download Progress Toast - Bottom Left */}
<DownloadProgressToast onClick={downloadQueue.openQueue} />
{/* Download Queue Dialog */}
<DownloadQueue
isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue}
/>
{/* Jump to Top Button - Bottom Right */}
{showScrollTop && (
<Button
onClick={scrollToTop}
className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg"
size="icon"
>
<ArrowUp className="h-5 w-5" />
</Button>
)}
</div>
</div>
</div>
</TooltipProvider>
);
}
+63 -4
View File
@@ -1,7 +1,8 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
@@ -39,13 +40,23 @@ interface AlbumInfoProps {
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
@@ -77,13 +88,22 @@ export function AlbumInfo({
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
@@ -128,7 +148,9 @@ export function AlbumInfo({
<span></span>
<span>{albumInfo.release_date}</span>
<span></span>
<span>{albumInfo.total_tracks} songs</span>
<span>
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
@@ -154,6 +176,38 @@ export function AlbumInfo({
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
@@ -204,6 +258,11 @@ export function AlbumInfo({
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onTrackClick={onTrackClick}
+62 -3
View File
@@ -1,7 +1,8 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
@@ -44,13 +45,23 @@ interface ArtistInfoProps {
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
@@ -84,13 +95,22 @@ export function ArtistInfo({
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
@@ -196,6 +216,40 @@ export function ArtistInfo({
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
size="sm"
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
size="sm"
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4" />
@@ -243,6 +297,11 @@ export function ArtistInfo({
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
+66 -99
View File
@@ -1,4 +1,4 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
import {
@@ -18,13 +18,15 @@ interface AudioAnalysisProps {
analyzing: boolean;
onAnalyze?: () => void;
showAnalyzeButton?: boolean;
filePath?: string;
}
export function AudioAnalysis({
result,
analyzing,
onAnalyze,
showAnalyzeButton = true
showAnalyzeButton = true,
filePath
}: AudioAnalysisProps) {
if (analyzing) {
return (
@@ -44,7 +46,7 @@ export function AudioAnalysis({
<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-muted-foreground/50" />
<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">
@@ -77,116 +79,81 @@ export function AudioAnalysis({
return num.toFixed(2);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
// Calculate Nyquist frequency (half of sample rate)
const nyquistFreq = result.sample_rate / 2;
// Calculate approximate data size (uncompressed PCM)
// Formula: sample_rate * channels * (bits_per_sample / 8) * duration
const dataSizeBytes = result.sample_rate * result.channels * (result.bits_per_sample / 8) * result.duration;
const dataSizeMB = dataSizeBytes / (1024 * 1024);
const formatDataSize = (mb: number) => {
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
}
return `${mb.toFixed(2)} MB`;
};
return (
<Card>
<Card className="gap-2">
<CardHeader>
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Audio Quality Analysis
</CardTitle>
<CardDescription>
Technical analysis of audio file properties
</CardDescription>
</div>
{filePath && (
<p className="text-sm font-mono break-all">{filePath}</p>
)}
</CardHeader>
<CardContent className="px-6 space-y-6">
{/* Technical Specifications */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Radio className="h-3 w-3" />
Sample Rate
<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>
<p className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</p>
<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>
{result.file_size > 0 && (
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Size:</span>
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
</div>
)}
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileAudio className="h-3 w-3" />
Bit Depth
{/* 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>
<p className="font-semibold">{result.bit_depth}</p>
<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="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waves className="h-3 w-3" />
Channels
<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>
<p className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}</p>
<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 className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Duration
</div>
<p className="font-semibold">{formatDuration(result.duration)}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Gauge className="h-3 w-3" />
Nyquist Frequency
</div>
<p className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3 w-3" />
Data Size
</div>
<p className="font-semibold">{formatDataSize(dataSizeMB)}</p>
</div>
</div>
{/* Dynamic Range Analysis */}
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="flex items-center gap-2 text-sm font-medium">
<TrendingUp className="h-4 w-4" />
Dynamic Range Analysis
</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Dynamic Range</p>
<p className="font-semibold">{formatNumber(result.dynamic_range)} dB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Peak Level</p>
<p className="font-semibold">{formatNumber(result.peak_amplitude)} dB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">RMS Level</p>
<p className="font-semibold">{formatNumber(result.rms_level)} dB</p>
</div>
</div>
</div>
{/* Technical Info Footer */}
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
Total Samples: {result.total_samples.toLocaleString()}
</p>
</div>
</CardContent>
</Card>
+27 -34
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Activity, Upload, ArrowLeft } from "lucide-react";
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
@@ -9,19 +9,17 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps {
onBack: () => void;
onBack?: () => void;
}
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
@@ -46,7 +44,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
return;
}
setSelectedFilePath(filePath);
await analyzeFile(filePath);
},
[analyzeFile]
@@ -64,31 +61,35 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const handleAnalyzeAnother = () => {
clearResult();
setSelectedFilePath("");
};
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>
<div>
)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
<p className="text-sm text-muted-foreground">
Analyze FLAC files to verify true lossless quality
</p>
</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 py-16 border-2 border-dashed rounded-lg transition-colors ${
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 hover:border-muted-foreground/50"
: "border-muted-foreground/30"
}`}
onDragOver={(e) => {
e.preventDefault();
@@ -104,11 +105,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
<Activity
className={`h-20 w-20 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`}
/>
<h3 className="text-xl font-medium mb-2">Analyze FLAC Audio Quality</h3>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
<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"}
@@ -131,30 +131,23 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* File Info */}
<div className="p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground">Analyzing file:</p>
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
</div>
{/* 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}
/>
{/* Detailed Analysis */}
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} />
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button onClick={handleAnalyzeAnother} variant="outline">
<Upload className="h-4 w-4" />
Analyze Another File
</Button>
</div>
)}
</div>
)}
</div>
@@ -0,0 +1,671 @@
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,
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;
size: number;
status: "pending" | "converting" | "success" | "error";
error?: string;
outputPath?: string;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" },
{ value: "256k", label: "256k" },
{ value: "192k", label: "192k" },
{ value: "128k", label: "128k" },
];
const M4A_CODEC_OPTIONS = [
{ value: "aac", label: "AAC" },
{ value: "alac", label: "ALAC" },
];
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 [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") {
return parsed.m4aCodec;
}
}
} catch (err) {
// Ignore
}
return "aac";
});
const [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = 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; m4aCodec: "aac" | "alac" }) => {
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, bitrate, or m4aCodec changes
useEffect(() => {
saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, m4aCodec, 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");
}
// Reset to AAC if no FLAC files (ALAC doesn't make sense for lossy input)
const hasFlac = files.some((f) => f.format === "flac");
if (!hasFlac && m4aCodec === "alac") {
setM4aCodec("aac");
}
}, [files, outputFormat, m4aCodec]);
// Check if format selection should be disabled (all files are MP3)
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
// Check if any file is FLAC (ALAC only makes sense for lossless input)
const hasFlacFiles = files.some((f) => f.format === "flac");
// 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 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(async (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.",
});
}
// Get file sizes from backend
const GetFileSizes = (files: string[]): Promise<Record<string, number>> =>
(window as any)["go"]["main"]["App"]["GetFileSizes"](files);
const validPaths = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
});
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
setFiles((prev) => {
const newFiles: AudioFile[] = validPaths
.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,
size: fileSizes[path] || 0,
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,
codec: outputFormat === "m4a" ? m4aCodec : "",
});
// 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]"
} border-muted-foreground/30`}
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Download className="h-8 w-8 text-primary" />
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
FFmpeg is required to convert audio files
</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-4 text-center">
{isDragging
? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"}
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5" />
Select Files
</Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3
</p>
</>
) : (
<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>
{/* Codec selection for M4A - only show ALAC option when input has FLAC files */}
{outputFormat === "m4a" && hasFlacFiles && (
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Codec:</Label>
<ToggleGroup
type="single"
variant="outline"
value={m4aCodec}
onValueChange={(value) => {
if (value) setM4aCodec(value as "aac" | "alac");
}}
>
{M4A_CODEC_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)}
{/* Bitrate selection - hide for ALAC (lossless) */}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (
<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 text-muted-foreground">
{formatFileSize(file.size)}
</span>
<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>
);
}
-132
View File
@@ -1,132 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { Bug, Trash2, X, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
};
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export function DebugLogger() {
const [open, setOpen] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [copied, setCopied] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const unsubscribe = logger.subscribe(() => {
setLogs(logger.getLogs());
});
setLogs(logger.getLogs());
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (err) {
console.error("Failed to copy logs:", err);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-50 hover:opacity-100"
>
<Bug className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] p-6 [&>button]:hidden">
<DialogTitle className="text-sm font-medium">Debug Logs</DialogTitle>
<div className="absolute right-4 top-4 flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleCopy}
disabled={logs.length === 0}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleClear}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={() => setOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div
ref={scrollRef}
className="h-[400px] overflow-y-auto bg-muted/50 rounded-md p-3 font-mono text-xs"
>
{logs.length === 0 ? (
<p className="text-muted-foreground lowercase">no logs yet...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
};
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export function DebugLoggerPage() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [copied, setCopied] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const unsubscribe = logger.subscribe(() => {
setLogs(logger.getLogs());
});
setLogs(logger.getLogs());
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (err) {
console.error("Failed to copy logs:", err);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleCopy}
disabled={logs.length === 0}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
Copy
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleClear}
disabled={logs.length === 0}
>
<Trash2 className="h-4 w-4" />
Clear
</Button>
</div>
</div>
<div
ref={scrollRef}
className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs"
>
{logs.length === 0 ? (
<p className="text-muted-foreground lowercase">no logs yet...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>
))
)}
</div>
</div>
);
}
+3 -2
View File
@@ -9,17 +9,18 @@ interface DownloadProgressProps {
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={progress} className="h-2 flex-1" />
<Progress value={clampedProgress} className="h-2 flex-1" />
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" />
Stop
</Button>
</div>
<p className="text-xs text-muted-foreground">
{progress}% -{" "}
{clampedProgress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
@@ -22,7 +22,7 @@ export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
}
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">
<Button
variant="outline"
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
+1 -1
View File
@@ -120,7 +120,7 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
<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">
<div className="flex items-center justify-between mb-4">
<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) && (
+832
View File
@@ -0,0 +1,832 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FolderOpen,
RefreshCw,
FileMusic,
ChevronRight,
ChevronDown,
Pencil,
Eye,
Folder,
Info,
RotateCcw,
FileText,
Image,
Copy,
Check,
} from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { getSettings } from "@/lib/settings";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> =>
(window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> =>
(window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> =>
(window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> =>
(window as any)['go']['main']['App']['ReadFileMetadata'](path);
const IsFFprobeInstalled = (): Promise<boolean> =>
(window as any)['go']['main']['App']['IsFFprobeInstalled']();
const DownloadFFmpeg = (): Promise<{ success: boolean; message: string; error?: string }> =>
(window as any)['go']['main']['App']['DownloadFFmpeg']();
const ReadTextFile = (path: string): Promise<string> =>
(window as any)['go']['main']['App']['ReadTextFile'](path);
const RenameFileTo = (oldPath: string, newName: string): Promise<void> =>
(window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
const ReadImageAsBase64 = (path: string): Promise<string> =>
(window as any)['go']['main']['App']['ReadImageAsBase64'](path);
interface FileNode {
name: string;
path: string;
is_dir: boolean;
size: number;
children?: FileNode[];
expanded?: boolean;
}
interface FileMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
type TabType = "track" | "lyric" | "cover";
const FORMAT_PRESETS: Record<string, { 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}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
const STORAGE_KEY = "spotiflac_file_manager_state";
const DEFAULT_PRESET = "title-artist";
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
export function FileManagerPage() {
const [rootPath, setRootPath] = useState(() => {
const settings = getSettings();
return settings.downloadPath || "";
});
const [allFiles, setAllFiles] = useState<FileNode[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>("track");
const [formatPreset, setFormatPreset] = useState<string>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
return parsed.formatPreset;
}
}
} catch { /* ignore */ }
return DEFAULT_PRESET;
});
const [customFormat, setCustomFormat] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.customFormat) return parsed.customFormat;
}
} catch { /* ignore */ }
return DEFAULT_CUSTOM_FORMAT;
});
const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template;
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
const [renaming, setRenaming] = useState(false);
const [previewOnly, setPreviewOnly] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showMetadata, setShowMetadata] = useState(false);
const [metadataFile, setMetadataFile] = useState<string>("");
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
const [loadingMetadata, setLoadingMetadata] = useState(false);
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
const [installingFFprobe, setInstallingFFprobe] = useState(false);
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
const [lyricsContent, setLyricsContent] = useState("");
const [lyricsFile, setLyricsFile] = useState("");
const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced");
const [copySuccess, setCopySuccess] = useState(false);
const [showCoverPreview, setShowCoverPreview] = useState(false);
const [coverFile, setCoverFile] = useState("");
const [coverData, setCoverData] = useState("");
const [showManualRename, setShowManualRename] = useState(false);
const [manualRenameFile, setManualRenameFile] = useState("");
const [manualRenameName, setManualRenameName] = useState("");
const [manualRenaming, setManualRenaming] = useState(false);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
} catch { /* ignore */ }
}, [formatPreset, customFormat]);
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => {
return nodes
.map((node) => {
if (node.is_dir && node.children) {
const filteredChildren = filterFilesByType(node.children, type);
if (filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
const ext = node.name.toLowerCase();
if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a"))) return node;
if (type === "lyric" && ext.endsWith(".lrc")) return node;
if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png"))) return node;
return null;
})
.filter((node): node is FileNode => node !== null);
};
const loadFiles = useCallback(async () => {
if (!rootPath) return;
setLoading(true);
try {
const result = await ListDirectoryFiles(rootPath);
if (!result || !Array.isArray(result)) {
setAllFiles([]);
setSelectedFiles(new Set());
return;
}
setAllFiles(result as FileNode[]);
setSelectedFiles(new Set());
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err || "");
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
toast.error("Failed to load files", { description: errorMsg || "Unknown error" });
}
setAllFiles([]);
setSelectedFiles(new Set());
} finally {
setLoading(false);
}
}, [rootPath]);
useEffect(() => {
if (rootPath) loadFiles();
}, [rootPath, loadFiles]);
const filteredFiles = filterFilesByType(allFiles, activeTab);
const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => {
const result: FileNode[] = [];
for (const node of nodes) {
if (!node.is_dir) result.push(node);
if (node.children) result.push(...getAllFilesFlat(node.children));
}
return result;
};
const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track"));
const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric"));
const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover"));
const handleSelectFolder = async () => {
try {
const path = await SelectFolder(rootPath);
if (path) setRootPath(path);
} catch (err) {
toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const toggleExpand = (path: string) => {
setAllFiles((prev) => toggleNodeExpand(prev, path));
};
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
return nodes.map((node) => {
if (node.path === path) return { ...node, expanded: !node.expanded };
if (node.children) return { ...node, children: toggleNodeExpand(node.children, path) };
return node;
});
};
const toggleSelect = (path: string) => {
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(path)) newSet.delete(path);
else newSet.add(path);
return newSet;
});
};
const toggleFolderSelect = (node: FileNode) => {
const folderFiles = getAllFilesFlat([node]);
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (allSelected) folderFiles.forEach((f) => newSet.delete(f.path));
else folderFiles.forEach((f) => newSet.add(f.path));
return newSet;
});
};
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
const folderFiles = getAllFilesFlat([node]);
if (folderFiles.length === 0) return false;
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
if (selectedCount === 0) return false;
if (selectedCount === folderFiles.length) return true;
return "indeterminate";
};
const selectAll = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
const deselectAll = () => setSelectedFiles(new Set());
const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); };
const handlePreview = async (isPreviewOnly: boolean) => {
if (selectedFiles.size === 0) { toast.error("No files selected"); return; }
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
if (hasM4A) {
const installed = await IsFFprobeInstalled();
if (!installed) { setShowFFprobeDialog(true); return; }
}
try {
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
setPreviewData(result);
setPreviewOnly(isPreviewOnly);
setShowPreview(true);
} catch (err) {
toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
if (filePath.toLowerCase().endsWith(".m4a")) {
const installed = await IsFFprobeInstalled();
if (!installed) { setShowFFprobeDialog(true); return; }
}
setMetadataFile(filePath);
setLoadingMetadata(true);
try {
const metadata = await ReadFileMetadata(filePath);
setMetadataInfo(metadata as FileMetadata);
setShowMetadata(true);
} catch (err) {
toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" });
setMetadataInfo(null);
} finally {
setLoadingMetadata(false);
}
};
const handleInstallFFprobe = async () => {
setInstallingFFprobe(true);
try {
const result = await DownloadFFmpeg();
if (result.success) { toast.success("FFprobe installed successfully"); setShowFFprobeDialog(false); }
else toast.error("Failed to install FFprobe", { description: result.error || result.message });
} catch (err) {
toast.error("Failed to install FFprobe", { description: err instanceof Error ? err.message : "Unknown error" });
} finally {
setInstallingFFprobe(false);
}
};
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setLyricsFile(filePath);
setLyricsTab("synced");
try {
const content = await ReadTextFile(filePath);
setLyricsContent(content);
setShowLyricsPreview(true);
} catch (err) {
toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowCover = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(filePath);
try {
const data = await ReadImageAsBase64(filePath);
setCoverData(data);
setShowCoverPreview(true);
} catch (err) {
toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const getPlainLyrics = (content: string) => {
return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim();
};
const handleCopyLyrics = async () => {
try {
const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent);
await navigator.clipboard.writeText(textToCopy);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 500);
} catch { toast.error("Failed to copy lyrics"); }
};
const handleManualRename = (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileName = filePath.split(/[/\\]/).pop() || "";
const nameWithoutExt = fileName.replace(/\.[^.]+$/, "");
setManualRenameFile(filePath);
setManualRenameName(nameWithoutExt);
setShowManualRename(true);
};
const handleConfirmManualRename = async () => {
if (!manualRenameFile || !manualRenameName.trim()) return;
setManualRenaming(true);
try {
await RenameFileTo(manualRenameFile, manualRenameName.trim());
toast.success("File renamed successfully");
setShowManualRename(false);
loadFiles();
} catch (err) {
toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" });
} finally {
setManualRenaming(false);
}
};
const handleRename = async () => {
if (selectedFiles.size === 0) return;
setRenaming(true);
try {
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
if (successCount > 0) toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` });
else toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` });
setShowPreview(false);
setSelectedFiles(new Set());
loadFiles();
} catch (err) {
toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" });
} finally {
setRenaming(false);
}
};
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
<div key={node.path}>
<div
className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}
>
{node.is_dir ? (
<>
<Checkbox
checked={isFolderSelected(node) === true}
ref={(el) => { if (el) (el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked"; }}
onCheckedChange={() => toggleFolderSelect(node)}
onClick={(e) => e.stopPropagation()}
className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"
/>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />}
<Folder className="h-4 w-4 text-yellow-500 shrink-0" />
</>
) : (
<>
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0" />
<FileMusic className="h-4 w-4 text-primary shrink-0" />
</>
)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (
<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>View Metadata</TooltipContent>
</Tooltip>
</>
)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
</div>
));
};
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
<div key={node.path}>
<div
className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}
>
{node.is_dir ? (
<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />}
<Folder className="h-4 w-4 text-yellow-500 shrink-0" />
</>
) : (
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (
<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>
)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
</div>
));
};
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
<div key={node.path}>
<div
className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}
>
{node.is_dir ? (
<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />}
<Folder className="h-4 w-4 text-yellow-500 shrink-0" />
</>
) : (
<Image className="h-4 w-4 text-green-500 shrink-0" />
)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (
<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>
)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
</div>
));
};
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
return (
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">File Manager</h1>
</div>
{/* Path Selection */}
<div className="flex items-center gap-2 shrink-0">
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1" />
<Button onClick={handleSelectFolder}>
<FolderOpen className="h-4 w-4" />
Browse
</Button>
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
<FileMusic className="h-4 w-4" />
Track ({allAudioFiles.length})
</Button>
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
<FileText className="h-4 w-4" />
Lyric ({allLyricFiles.length})
</Button>
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
<Image className="h-4 w-4" />
Cover ({allCoverFiles.length})
</Button>
</div>
{/* Rename Format - Only for Track tab */}
{activeTab === "track" && (
<div className="space-y-2 shrink-0">
<div className="flex items-center gap-2">
<Label className="text-sm">Rename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Select value={formatPreset} onValueChange={setFormatPreset}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{formatPreset === "custom" && (
<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1" />
)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset to Default</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>
</div>
)}
{/* File Tree */}
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
{activeTab === "track" && (
<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? "Deselect All" : "Select All"}
</Button>
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
<Eye className="h-4 w-4" />
Preview
</Button>
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
<Pencil className="h-4 w-4" />
Rename
</Button>
</div>
</div>
)}
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
{loading ? (
<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6" /></div>
) : filteredFiles.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
</div>
) : (
activeTab === "track" ? renderTrackTree(filteredFiles) :
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
renderCoverTree(filteredFiles)
)}
</div>
</div>
{/* Reset Confirmation Dialog */}
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={resetToDefault}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename Preview</DialogTitle>
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 py-4">
{previewData.map((item, index) => (
<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
<div className="text-sm">
<div className="text-muted-foreground break-all">{item.old_name}</div>
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1"> {item.new_name}</div>}
</div>
</div>
))}
</div>
<DialogFooter>
{previewOnly ? (
<Button onClick={() => setShowPreview(false)}>Close</Button>
) : (
<>
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
<Button onClick={handleRename} disabled={renaming}>
{renaming ? <><Spinner className="h-4 w-4" />Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Metadata Dialog */}
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>File Metadata</DialogTitle>
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
{loadingMetadata ? (
<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6" /></div>
) : metadataInfo ? (
<div className="space-y-3 py-2">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
</div>
) : (
<div className="text-center py-4 text-muted-foreground">No metadata available</div>
)}
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
{/* FFprobe Install Dialog */}
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>FFprobe Required</DialogTitle>
<DialogDescription>Reading M4A metadata requires FFprobe. Would you like to download and install it now?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>Cancel</Button>
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
{installingFFprobe ? <><Spinner className="h-4 w-4" />Installing...</> : "Install FFprobe"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Lyrics Preview Dialog */}
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Lyrics Preview</DialogTitle>
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 border-b pb-2">
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
</div>
<div className="flex-1 overflow-y-auto py-4">
<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
{lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent) || "No lyrics content"}
</pre>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
{copySuccess ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
Copy
</Button>
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cover Preview Dialog */}
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
<DialogContent className="max-w-lg [&>button]:hidden">
<DialogHeader>
<DialogTitle>Cover Preview</DialogTitle>
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center p-4">
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain" /> : <div className="text-muted-foreground">Loading...</div>}
</div>
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
{/* Manual Rename Dialog */}
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
<DialogContent className="max-w-2xl [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="newName" className="text-sm">New Name</Label>
<div className="flex items-center gap-2 mt-2">
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { if (e.key === "Enter" && !manualRenaming) handleConfirmManualRename(); }} />
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
{manualRenaming ? <><Spinner className="h-4 w-4" />Renaming...</> : "Rename"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+13 -36
View File
@@ -1,21 +1,19 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Settings } from "@/components/Settings";
import { Activity } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/relative-time";
interface HeaderProps {
version: string;
hasUpdate: boolean;
onOpenAudioAnalysis: () => void;
releaseDate?: string | null;
}
export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps) {
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return (
<div className="relative">
<div className="text-center space-y-2">
@@ -33,6 +31,8 @@ export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps)
SpotiFLAC
</h1>
<div className="relative">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" asChild>
<button
type="button"
@@ -42,6 +42,13 @@ export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps)
v{version}
</button>
</Badge>
</TooltipTrigger>
{hasUpdate && releaseDate && (
<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent>
)}
</Tooltip>
{hasUpdate && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
@@ -51,39 +58,9 @@ export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps)
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
</p>
</div>
<div className="absolute right-0 top-0 flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
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>
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Report bug or request feature</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={onOpenAudioAnalysis}>
<Activity className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
<Settings />
</div>
</div>
);
}
@@ -7,12 +7,6 @@ export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) =>
</svg>
);
export const DeezerIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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>
);
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>
+63 -4
View File
@@ -1,7 +1,8 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
@@ -43,13 +44,23 @@ interface PlaylistInfoProps {
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
@@ -82,13 +93,22 @@ export function PlaylistInfo({
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
@@ -117,7 +137,9 @@ export function PlaylistInfo({
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{playlistInfo.owner.display_name}</span>
<span></span>
<span>{playlistInfo.tracks.total} songs</span>
<span>
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
</span>
<span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
</div>
@@ -145,6 +167,38 @@ export function PlaylistInfo({
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
@@ -191,10 +245,15 @@ export function PlaylistInfo({
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
+464 -6
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Search, Info, XCircle } from "lucide-react";
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
@@ -10,16 +10,28 @@ import {
} from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory";
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils";
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8;
const SEARCH_LIMIT = 50;
interface SearchBarProps {
url: string;
loading: boolean;
onUrlChange: (url: string) => void;
onFetch: () => void;
onFetchUrl: (url: string) => Promise<void>;
history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void;
hasResult: boolean;
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
}
export function SearchBar({
@@ -27,27 +39,257 @@ export function SearchBar({
loading,
onUrlChange,
onFetch,
onFetchUrl,
history,
onHistorySelect,
onHistoryRemove,
hasResult,
searchMode,
onSearchModeChange,
}: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
tracks: false,
albums: false,
artists: false,
playlists: false,
});
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load recent searches from localStorage
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
if (saved) {
setRecentSearches(JSON.parse(saved));
}
} catch (error) {
console.error("Failed to load recent searches:", error);
}
}, []);
const saveRecentSearch = (query: string) => {
const trimmed = query.trim();
if (!trimmed) return;
setRecentSearches((prev) => {
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
const removeRecentSearch = (query: string) => {
setRecentSearches((prev) => {
const updated = prev.filter((s) => s !== query);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
// Debounced search - only search if query changed
useEffect(() => {
if (!searchMode || !searchQuery.trim()) {
return;
}
// Don't search again if query is the same
if (searchQuery.trim() === lastSearchedQuery) {
return;
}
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
setSearchResults(results);
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
// Check if there might be more results
setHasMore({
tracks: results.tracks.length === SEARCH_LIMIT,
albums: results.albums.length === SEARCH_LIMIT,
artists: results.artists.length === SEARCH_LIMIT,
playlists: results.playlists.length === SEARCH_LIMIT,
});
// Auto-select first tab with results
if (results.tracks.length > 0) setActiveTab("tracks");
else if (results.albums.length > 0) setActiveTab("albums");
else if (results.artists.length > 0) setActiveTab("artists");
else if (results.playlists.length > 0) setActiveTab("playlists");
} catch (error) {
console.error("Search failed:", error);
setSearchResults(null);
} finally {
setIsSearching(false);
}
}, 400);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchMode, lastSearchedQuery]);
const handleLoadMore = async () => {
if (!searchResults || !lastSearchedQuery || isLoadingMore) return;
const typeMap: Record<ResultTab, string> = {
tracks: "track",
albums: "album",
artists: "artist",
playlists: "playlist",
};
const currentCount = getTabCount(activeTab);
setIsLoadingMore(true);
try {
const moreResults = await SearchSpotifyByType({
query: lastSearchedQuery,
search_type: typeMap[activeTab],
limit: SEARCH_LIMIT,
offset: currentCount,
});
if (moreResults.length > 0) {
setSearchResults((prev) => {
if (!prev) return prev;
// Create new SearchResponse with updated array for the active tab
const updated = new backend.SearchResponse({
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
});
return updated;
});
}
// Update hasMore for this tab
setHasMore((prev) => ({
...prev,
[activeTab]: moreResults.length === SEARCH_LIMIT,
}));
} catch (error) {
console.error("Load more failed:", error);
} finally {
setIsLoadingMore(false);
}
};
const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false);
onFetchUrl(externalUrl);
};
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const hasAnyResults = searchResults && (
searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0
);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults) return 0;
switch (tab) {
case "tracks": return searchResults.tracks.length;
case "albums": return searchResults.albums.length;
case "artists": return searchResults.artists.length;
case "playlists": return searchResults.playlists.length;
}
};
const tabs: { key: ResultTab; label: string }[] = [
{ key: "tracks", label: "Tracks" },
{ key: "albums", label: "Albums" },
{ key: "artists", label: "Artists" },
{ key: "playlists", label: "Playlists" },
];
return (
<div className="space-y-3">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
{/* Mode Toggle */}
<div className="flex items-center bg-muted rounded-md p-1">
<button
type="button"
onClick={() => onSearchModeChange(false)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
!searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Link className="h-3.5 w-3.5" />
URL
</button>
<button
type="button"
onClick={() => onSearchModeChange(true)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Search className="h-3.5 w-3.5" />
Search
</button>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
{!searchMode ? (
<>
<p>Supports track, album, playlist, and artist URLs</p>
<p className="mt-1">Note: Playlist must be public (not private)</p>
</>
) : (
<p>Search for tracks, albums, artists, or playlists</p>
)}
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
{!searchMode ? (
<>
<InputWithContext
id="spotify-url"
placeholder="https://open.spotify.com/..."
@@ -65,7 +307,34 @@ export function SearchBar({
<XCircle className="h-4 w-4" />
</button>
)}
</>
) : (
<>
<InputWithContext
id="spotify-search"
placeholder="Search tracks, albums, artists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="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={() => {
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
}}
>
<XCircle className="h-4 w-4" />
</button>
)}
</>
)}
</div>
{!searchMode && (
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
@@ -74,20 +343,209 @@ export function SearchBar({
</>
) : (
<>
<Search className="h-4 w-4" />
<CloudDownload className="h-4 w-4" />
Fetch
</>
)}
</Button>
)}
</div>
</div>
{!hasResult && (
{!searchMode && !hasResult && (
<FetchHistory
history={history}
onSelect={onHistorySelect}
onRemove={onHistoryRemove}
/>
)}
{/* Search Results with Tabs */}
{searchMode && (
<div className="space-y-4">
{/* Recent Searches - show when no query or no results yet */}
{!searchQuery && !searchResults && recentSearches.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (
<div
key={query}
className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors"
onClick={() => setSearchQuery(query)}
>
<span>{query}</span>
<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();
removeRecentSearch(query);
}}
>
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
</button>
</div>
))}
</div>
</div>
)}
{isSearching && (
<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>
)}
{!isSearching && searchQuery && !hasAnyResults && (
<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>
)}
{!isSearching && hasAnyResults && (
<>
{/* Tabs */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0) return null;
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px",
activeTab === tab.key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label} ({count})
</button>
);
})}
</div>
{/* Tab Content */}
<div className="grid gap-2">
{/* Tracks */}
{activeTab === "tracks" && searchResults?.tracks.map((track) => (
<button
key={track.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(track.external_urls)}
>
{track.images ? (
<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.name}</p>
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>
))}
{/* Albums */}
{activeTab === "albums" && searchResults?.albums.map((album) => (
<button
key={album.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(album.external_urls)}
>
{album.images ? (
<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{album.total_tracks} tracks
</span>
</button>
))}
{/* Artists */}
{activeTab === "artists" && searchResults?.artists.map((artist) => (
<button
key={artist.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(artist.external_urls)}
>
{artist.images ? (
<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded-full bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p>
</div>
</button>
))}
{/* Playlists */}
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (
<button
key={playlist.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(playlist.external_urls)}
>
{playlist.images ? (
<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate">
{playlist.owner} {playlist.total_tracks} tracks
</p>
</div>
</button>
))}
</div>
{/* Load More Button */}
{hasMore[activeTab] && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Spinner />
Loading...
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Load More
</>
)}
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
-423
View File
@@ -1,423 +0,0 @@
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 {
Dialog,
DialogContent,
DialogFooter,
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, X } 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" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
const DeezerIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>
);
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>
);
export function Settings() {
const [open, setOpen] = useState(false);
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [, setIsLoadingDefaults] = useState(false);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
// Apply saved settings
useEffect(() => {
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);
// Update isDark state after theme is applied
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
// Setup listener for system theme changes during preview
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (tempSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(tempSettings.theme);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}
};
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" | "amazon") => {
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-[600px] flex flex-col p-6 [&>button]:hidden" aria-describedby={undefined}>
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleCancel}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Settings</DialogTitle>
<div className="grid grid-cols-[1.2fr_0.8fr] gap-6 py-2">
{/* Left Column */}
<div className="space-y-4">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => handleDownloadPathChange(e.target.value)}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
</div>
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<Select
value={tempSettings.downloader}
onValueChange={handleDownloaderChange}
>
<SelectTrigger id="downloader">
<SelectValue placeholder="Select a source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="deezer">
<span className="flex items-center">
<DeezerIcon />
Deezer
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Mode Selection */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Theme</Label>
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Color Selection */}
<div className="space-y-2">
<Label htmlFor="theme">Theme Color</Label>
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full border border-border"
style={{
backgroundColor: isDark
? theme.cssVars.dark.primary
: theme.cssVars.light.primary
}}
/>
{theme.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 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" className="max-w-xs">
<p className="text-xs text-center">Adds track numbers to filenames. Uses album track numbers when Album Subfolder is enabled, otherwise uses playlist position</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
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</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist only</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
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</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist & Discography</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="outline" onClick={handleReset} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+440
View File
@@ -0,0 +1,440 @@
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { 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'));
const [showResetConfirm, setShowResetConfirm] = useState(false);
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);
// Save to localStorage so it persists on reload
saveSettings(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);
setShowResetConfirm(false);
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>
<div className="flex gap-2">
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
}));
}}
>
<SelectTrigger className="h-9 w-fit">
<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 flex-1"
/>
)}
</div>
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</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>
<div className="flex gap-2">
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
}));
}}
>
<SelectTrigger className="h-9 w-fit">
<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 flex-1"
/>
)}
</div>
{tempSettings.filenameTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
{/* Reset Confirmation Dialog */}
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset all settings to their default values. Your custom configurations will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { FileMusic, FilePen } from "lucide-react";
import { HomeIcon } from "@/components/ui/home";
import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks";
import { CoffeeIcon } from "@/components/ui/coffee";
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" | "file-manager";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
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">
{/* Home */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "main" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("main")}
>
<HomeIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Home</p>
</TooltipContent>
</Tooltip>
{/* Settings */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "settings" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("settings")}
>
<SettingsIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</Tooltip>
{/* Audio Analysis */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-analysis")}
>
<ActivityIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
{/* Audio Converter - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "audio-converter" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-converter")}
>
<FileMusic className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Converter</p>
</TooltipContent>
</Tooltip>
{/* File Manager - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "file-manager" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("file-manager")}
>
<FilePen className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>File Manager</p>
</TooltipContent>
</Tooltip>
{/* Debug */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "debug" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("debug")}
>
<TerminalIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Debug Logs</p>
</TooltipContent>
</Tooltip>
</div>
{/* Bottom icons */}
<div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
>
<GithubIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bug</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")}
>
<BlocksIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Other Projects</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
>
<CoffeeIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
+16 -31
View File
@@ -1,10 +1,7 @@
import { useState } from "react";
import { X, Minus, Square } from "lucide-react";
import { X, Minus, Maximize } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() {
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const handleMinimize = () => {
WindowMinimise();
};
@@ -21,48 +18,36 @@ export function TitleBar() {
<>
{/* Draggable area */}
<div
className="fixed top-0 left-0 right-0 h-12 z-40"
className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm"
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
onDoubleClick={handleMaximize}
/>
{/* Window control buttons */}
<div className="fixed top-4 left-4 z-50 flex gap-2">
<button
onClick={handleClose}
onMouseEnter={() => setHoveredButton("close")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
{hoveredButton === "close" && (
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
)}
</button>
{/* Window control buttons - Windows style, right side */}
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
<button
onClick={handleMinimize}
onMouseEnter={() => setHoveredButton("minimize")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Minimize"
>
{hoveredButton === "minimize" && (
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
)}
<Minus className="w-3.5 h-3.5" />
</button>
<button
onClick={handleMaximize}
onMouseEnter={() => setHoveredButton("maximize")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Maximize"
>
{hoveredButton === "maximize" && (
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
)}
<Maximize className="w-3.5 h-3.5" />
</button>
<button
onClick={handleClose}
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</>
+72 -37
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe } from "lucide-react";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
@@ -8,7 +8,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackInfoProps {
track: TrackMetadata & { album_name: string; release_date: string };
@@ -23,9 +23,14 @@ interface TrackInfoProps {
skippedLyrics?: boolean;
checkingAvailability?: boolean;
availability?: TrackAvailability;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
downloadingCover?: boolean;
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void;
}
@@ -42,9 +47,14 @@ export function TrackInfo({
skippedLyrics,
checkingAvailability,
availability,
downloadingCover,
downloadedCover,
failedCover,
skippedCover,
onDownload,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onOpenFolder,
}: TrackInfoProps) {
return (
@@ -87,7 +97,7 @@ export function TrackInfo({
{track.isrc && (
<div className="flex gap-2 flex-wrap">
<Button
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)}
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
@@ -99,42 +109,11 @@ export function TrackInfo({
</>
)}
</Button>
{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"}`} />
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "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>
)}
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)}
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
@@ -156,6 +135,62 @@ export function TrackInfo({
</TooltipContent>
</Tooltip>
)}
{track.images && onDownloadCover && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)}
variant="outline"
disabled={downloadingCover}
>
{downloadingCover ? (
<Spinner />
) : skippedCover ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedCover ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedCover ? (
<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)}
variant="outline"
disabled={checkingAvailability}
>
{checkingAvailability ? (
<Spinner />
) : availability ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
{isDownloaded && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
+98 -39
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe } from "lucide-react";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
@@ -16,7 +16,7 @@ import {
PaginationPrevious,
} from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackListProps {
tracks: TrackMetadata[];
@@ -42,11 +42,17 @@ interface TrackListProps {
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => 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, albumArtist?: string, releaseDate?: string, discNumber?: 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, albumArtist?: string, releaseDate?: string, discNumber?: 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;
@@ -75,11 +81,16 @@ export function TrackList({
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onPageChange,
onAlbumClick,
onArtistClick,
@@ -286,23 +297,101 @@ export function TrackList({
<td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1">
{track.isrc && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)
}
size="sm"
className="gap-1.5"
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : skippedTracks.has(track.isrc) ? (
<FileCheck className="h-4 w-4" />
) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4" />
) : failedTracks.has(track.isrc) ? (
<XCircle className="h-4 w-4" />
) : (
<>
<Download className="h-4 w-4" />
Download
</>
)}
</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, track.album_artist, track.release_date, track.disc_number)
}
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, track.album_artist, track.release_date, track.disc_number);
}}
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>
@@ -326,7 +415,6 @@ export function TrackList({
{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"}`} />
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "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>
@@ -336,35 +424,6 @@ export function TrackList({
</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>
)}
</div>
</td>
</tr>
+104
View File
@@ -0,0 +1,104 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ActivityIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
pathOffset: 0,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
pathOffset: [1, 0],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
};
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
variants={PATH_VARIANTS}
animate={controls}
initial="normal"
/>
</svg>
</div>
);
}
);
ActivityIcon.displayName = 'ActivityIcon';
export { ActivityIcon };
+92
View File
@@ -0,0 +1,92 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface BlocksIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const VARIANTS: Variants = {
normal: { translateX: 0, translateY: 0 },
animate: { translateX: -4, translateY: 4 },
};
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" />
<motion.path
d="M14 3h7v7h-7z"
variants={VARIANTS}
animate={controls}
/>
</svg>
</div>
);
}
);
BlocksIcon.displayName = 'BlocksIcon';
export { BlocksIcon };
+118
View File
@@ -0,0 +1,118 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface CoffeeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
y: 0,
opacity: 1,
},
animate: (custom: number) => ({
y: -3,
opacity: [0, 1, 0],
transition: {
repeat: Infinity,
duration: 1.5,
ease: 'easeInOut',
delay: 0.2 * custom,
},
}),
};
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ overflow: 'visible' }}
>
<motion.path
d="M10 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0.2}
/>
<motion.path
d="M14 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0.4}
/>
<motion.path
d="M6 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0}
/>
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" />
</svg>
</div>
);
}
);
CoffeeIcon.displayName = 'CoffeeIcon';
export { CoffeeIcon };
+149
View File
@@ -0,0 +1,149 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.9, 1],
transition: {
duration: 0.4,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
draw: {
pathLength: [0, 1],
rotate: 0,
transition: {
duration: 0.5,
},
},
wag: {
pathLength: 1,
rotate: [0, -15, 15, -10, 10, -5, 5],
transition: {
duration: 2.5,
ease: 'easeInOut',
repeat: Infinity,
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
},
stopAnimation: () => {
bodyControls.start('normal');
tailControls.start('normal');
},
};
});
const handleMouseEnter = useCallback(
async (e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
} else {
onMouseEnter?.(e);
}
},
[bodyControls, onMouseEnter, tailControls]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('normal');
tailControls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[bodyControls, tailControls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
variants={BODY_VARIANTS}
initial="normal"
animate={bodyControls}
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
/>
<motion.path
variants={TAIL_VARIANTS}
initial="normal"
animate={tailControls}
d="M9 18c-4.51 2-5-2-7-2"
/>
</svg>
</div>
);
}
);
GithubIcon.displayName = 'GithubIcon';
export { GithubIcon };
+103
View File
@@ -0,0 +1,103 @@
'use client';
import type { Transition, Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface HomeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DEFAULT_TRANSITION: Transition = {
duration: 0.6,
opacity: { duration: 0.2 },
};
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
},
};
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<motion.path
d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"
variants={PATH_VARIANTS}
transition={DEFAULT_TRANSITION}
animate={controls}
/>
</svg>
</div>
);
}
);
HomeIcon.displayName = 'HomeIcon';
export { HomeIcon };
@@ -1,45 +0,0 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary 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 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
@@ -1,46 +0,0 @@
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 }
+92
View File
@@ -0,0 +1,92 @@
'use client';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface SettingsIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transition={{ type: 'spring', stiffness: 50, damping: 10 }}
variants={{
normal: {
rotate: 0,
},
animate: {
rotate: 180,
},
}}
animate={controls}
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</motion.svg>
</div>
);
}
);
SettingsIcon.displayName = 'SettingsIcon';
export { SettingsIcon };
+1
View File
@@ -36,6 +36,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
left: "calc(56px + 1rem)",
} as React.CSSProperties
}
{...props}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
-66
View File
@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+103
View File
@@ -0,0 +1,103 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface TerminalIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const LINE_VARIANTS: Variants = {
normal: { opacity: 1 },
animate: {
opacity: [1, 0, 1],
transition: {
duration: 0.8,
repeat: Infinity,
ease: 'linear',
},
},
};
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<motion.line
x1="12"
x2="20"
y1="19"
y2="19"
variants={LINE_VARIANTS}
animate={controls}
initial="normal"
/>
</svg>
</div>
);
}
);
TerminalIcon.displayName = 'TerminalIcon';
export { TerminalIcon };
@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+114 -2
View File
@@ -1,13 +1,63 @@
import { useState, useCallback } from "react";
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>(null);
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) {
@@ -18,6 +68,7 @@ export function useAudioAnalysis() {
setAnalyzing(true);
setError(null);
setResult(null);
setSelectedFilePath(filePath);
try {
logger.info(`Analyzing audio file: ${filePath}`);
@@ -29,7 +80,24 @@ export function useAudioAnalysis() {
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) {
@@ -48,12 +116,56 @@ export function useAudioAnalysis() {
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,
};
+253
View File
@@ -0,0 +1,253 @@
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,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => {
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,
album_name: albumName || "",
album_artist: albumArtist || "",
release_date: releaseDate || "",
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
disc_number: discNumber || 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__";
// Determine if we should use album track number or sequential position
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
// 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: trackPosition,
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,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: trackPosition,
disc_number: track.disc_number,
});
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,
};
}
+377 -151
View File
@@ -1,11 +1,32 @@
import { useState, useRef } from "react";
import { downloadTrack } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
// Type definitions for new backend functions
interface CheckFileExistenceRequest {
isrc: string;
track_name: string;
artist_name: string;
}
interface FileExistenceResult {
isrc: string;
exists: boolean;
file_path?: string;
track_name?: string;
artist_name?: string;
}
// These functions will be available after Wails regenerates bindings
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> =>
(window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> =>
(window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
export function useDownload() {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
@@ -22,17 +43,24 @@ export function useDownload() {
const downloadWithAutoFallback = async (
isrc: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: any,
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number,
spotifyId?: string,
durationMs?: number
durationMs?: number,
releaseYear?: string,
albumArtist?: string,
releaseDate?: string,
coverUrl?: string,
spotifyTrackNumber?: number,
spotifyDiscNumber?: number,
spotifyTotalTracks?: number
) => {
let service = settings.downloader;
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
@@ -40,24 +68,41 @@ export function useDownload() {
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),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
year: releaseYear,
playlist: playlistName?.replace(/\//g, placeholder),
isrc: isrc,
};
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true; // Use album track number for discography with album subfolder
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true; // Use album track number when both artist and album subfolders are used
// Apply folder template if available
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
// Use album track number if template contains {album}
if (settings.folderTemplate.includes("{album}")) {
useAlbumTrackNumber = true;
}
}
// Always add item to queue before downloading
@@ -66,6 +111,7 @@ export function useDownload() {
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 {
@@ -91,59 +137,37 @@ export function useDownload() {
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
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 deezer...`);
logger.warning(`tidal failed, trying amazon...`);
} catch (tidalErr) {
logger.error(`tidal error: ${tidalErr}`);
}
}
// Try Deezer second
if (streamingURLs?.deezer_url) {
try {
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
const deezerResponse = await downloadTrack({
isrc,
service: "deezer",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
item_id: itemID,
});
if (deezerResponse.success) {
logger.success(`deezer: ${trackName} - ${artistName}`);
return deezerResponse;
}
logger.warning(`deezer failed, trying amazon...`);
} catch (deezerErr) {
logger.error(`deezer error: ${deezerErr}`);
}
}
// Try Amazon third
// Try Amazon second
if (streamingURLs?.amazon_url) {
try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
@@ -154,14 +178,22 @@ export function useDownload() {
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
if (amazonResponse.success) {
@@ -183,14 +215,23 @@ export function useDownload() {
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: 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
@@ -206,21 +247,38 @@ export function useDownload() {
// Convert duration from ms to seconds for backend
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
// Determine audio format based on service
let audioFormat: string | undefined;
if (service === "tidal") {
audioFormat = settings.tidalQuality || "LOSSLESS";
} else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
service: service as "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationSecondsForFallback,
item_id: itemID, // Pass itemID for tracking
audio_format: audioFormat,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
// Mark as failed if download failed for single-service attempt
@@ -234,47 +292,73 @@ export function useDownload() {
const downloadWithItemID = async (
isrc: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings: any,
itemID: string,
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
folderName?: string,
position?: number,
spotifyId?: string,
durationMs?: number
durationMs?: number,
isAlbum?: boolean,
releaseYear?: string,
albumArtist?: string,
releaseDate?: string,
coverUrl?: string,
spotifyTrackNumber?: number,
spotifyDiscNumber?: number,
spotifyTotalTracks?: number
) => {
let service = settings.downloader;
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
year: releaseYear,
playlist: folderName?.replace(/\//g, placeholder),
isrc: isrc,
};
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
// 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));
}
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, 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;
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true;
}
}
}
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 {
@@ -298,15 +382,24 @@ export function useDownload() {
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
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) {
@@ -317,35 +410,7 @@ export function useDownload() {
}
}
// Try Deezer second
if (streamingURLs?.deezer_url) {
try {
const deezerResponse = await downloadTrack({
isrc,
service: "deezer",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
item_id: itemID,
});
if (deezerResponse.success) {
return deezerResponse;
}
} catch (deezerErr) {
console.error("Deezer error:", deezerErr);
}
}
// Try Amazon third
// Try Amazon second
if (streamingURLs?.amazon_url) {
try {
const amazonResponse = await downloadTrack({
@@ -355,14 +420,22 @@ export function useDownload() {
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
});
if (amazonResponse.success) {
@@ -381,14 +454,23 @@ export function useDownload() {
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: 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
@@ -403,21 +485,38 @@ export function useDownload() {
// 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 "deezer" | "tidal" | "qobuz" | "amazon",
service: service as "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationSecondsForFallback,
item_id: itemID,
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
@@ -436,8 +535,14 @@ export function useDownload() {
albumName?: string,
spotifyId?: string,
playlistName?: string,
isArtistDiscography?: boolean,
durationMs?: number
durationMs?: number,
position?: number,
albumArtist?: string,
releaseDate?: string,
coverUrl?: string,
spotifyTrackNumber?: number,
spotifyDiscNumber?: number,
spotifyTotalTracks?: number
) => {
if (!isrc) {
toast.error("No ISRC found for this track");
@@ -450,6 +555,9 @@ export function useDownload() {
try {
// Single track download - use playlistName if provided for folder structure
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = releaseDate?.substring(0, 4);
const response = await downloadWithAutoFallback(
isrc,
settings,
@@ -457,10 +565,16 @@ export function useDownload() {
artistName,
albumName,
playlistName,
isArtistDiscography,
undefined, // Don't pass position for single track
position, // Pass position for track numbering
spotifyId,
durationMs
durationMs,
releaseYear,
albumArtist || "",
releaseDate,
coverUrl,
spotifyTrackNumber, // Spotify album track number
spotifyDiscNumber, // Spotify disc number
spotifyTotalTracks // Total tracks in album
);
if (response.success) {
@@ -491,8 +605,8 @@ export function useDownload() {
const handleDownloadSelected = async (
selectedTracks: string[],
allTracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
folderName?: string,
isAlbum?: boolean
) => {
if (selectedTracks.length === 0) {
toast.error("No tracks selected");
@@ -505,7 +619,40 @@ export function useDownload() {
setBulkDownloadType("selected");
setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
// Build output directory path
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
if (folderName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
// Get selected track objects
const selectedTrackObjects = selectedTracks
.map((isrc) => allTracks.find((t) => t.isrc === isrc))
.filter((t): t is TrackMetadata => t !== undefined);
// Check file existence in parallel first
logger.info(`checking existing files in parallel...`);
const existenceChecks = selectedTrackObjects.map((track) => ({
isrc: track.isrc,
track_name: track.name || "",
artist_name: track.artists || "",
}));
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
const existingISRCs = new Set<string>();
const existingFilePaths = new Map<string, string>();
for (const result of existenceResults) {
if (result.exists) {
existingISRCs.add(result.isrc);
existingFilePaths.set(result.isrc, result.file_path || "");
}
}
logger.info(`found ${existingISRCs.size} existing files`);
// Pre-add ALL tracks to the queue and mark existing ones as skipped
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const isrc of selectedTracks) {
@@ -517,55 +664,78 @@ export function useDownload() {
track?.album_name || ""
);
itemIDs.push(itemID);
// Mark existing files as skipped immediately
if (existingISRCs.has(isrc)) {
const filePath = existingFilePaths.get(isrc) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(isrc));
setDownloadedTracks((prev) => new Set(prev).add(isrc));
}
}
// Filter out existing tracks
const tracksToDownload = selectedTrackObjects.filter((track) => !existingISRCs.has(track.isrc));
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
let skippedCount = existingISRCs.size;
const total = selectedTracks.length;
for (let i = 0; i < selectedTracks.length; i++) {
// Update progress to reflect already-skipped tracks
setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
);
break;
}
const isrc = selectedTracks[i];
const track = allTracks.find((t) => t.isrc === isrc);
const itemID = itemIDs[i];
const track = tracksToDownload[i];
const isrc = track.isrc;
// Find original index and itemID
const originalIndex = selectedTracks.indexOf(isrc);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(isrc);
if (track) {
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
}
try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track.release_date?.substring(0, 4);
// Download with pre-created itemID
const response = await downloadWithItemID(
isrc,
settings,
itemID,
track?.name,
track?.artists,
track?.album_name,
playlistName,
isArtistDiscography,
i + 1, // Sequential position based on selection order
track?.spotify_id,
track?.duration_ms
track.name,
track.artists,
track.album_name,
folderName,
originalIndex + 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.already_exists) {
skippedCount++;
logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`);
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}`);
logger.success(`downloaded: ${track.name} - ${track.artists}`);
}
setDownloadedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => {
@@ -575,19 +745,20 @@ export function useDownload() {
});
} else {
errorCount++;
logger.error(`failed: ${track?.name} - ${track?.artists}`);
logger.error(`failed: ${track.name} - ${track.artists}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
}
} catch (err) {
errorCount++;
logger.error(`error: ${track?.name} - ${err}`);
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));
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
@@ -622,8 +793,8 @@ export function useDownload() {
const handleDownloadAll = async (
tracks: TrackMetadata[],
playlistName?: string,
isArtistDiscography?: boolean
folderName?: string,
isAlbum?: boolean
) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc);
@@ -638,7 +809,35 @@ export function useDownload() {
setBulkDownloadType("all");
setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
// Build output directory path
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
if (folderName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
// Check file existence in parallel first
logger.info(`checking existing files in parallel...`);
const existenceChecks = tracksWithIsrc.map((track) => ({
isrc: track.isrc,
track_name: track.name || "",
artist_name: track.artists || "",
}));
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
const existingISRCs = new Set<string>();
const existingFilePaths = new Map<string, string>();
for (const result of existenceResults) {
if (result.exists) {
existingISRCs.add(result.isrc);
existingFilePaths.set(result.isrc, result.file_path || "");
}
}
logger.info(`found ${existingISRCs.size} existing files`);
// Pre-add ALL tracks to the queue and mark existing ones as skipped
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const track of tracksWithIsrc) {
@@ -649,28 +848,47 @@ export function useDownload() {
track.album_name || ""
);
itemIDs.push(itemID);
// Mark existing files as skipped immediately
if (existingISRCs.has(track.isrc)) {
const filePath = existingFilePaths.get(track.isrc) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
}
}
// Filter out existing tracks
const tracksToDownload = tracksWithIsrc.filter((track) => !existingISRCs.has(track.isrc));
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
let skippedCount = existingISRCs.size;
const total = tracksWithIsrc.length;
for (let i = 0; i < tracksWithIsrc.length; i++) {
// Update progress to reflect already-skipped tracks
setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
);
break;
}
const track = tracksWithIsrc[i];
const itemID = itemIDs[i];
const track = tracksToDownload[i];
// Find original index and itemID
const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(
track.isrc,
settings,
@@ -678,11 +896,18 @@ export function useDownload() {
track.name,
track.artists,
track.album_name,
playlistName,
isArtistDiscography,
i + 1,
folderName,
originalIndex + 1,
track.spotify_id,
track.duration_ms
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) {
@@ -714,7 +939,8 @@ export function useDownload() {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
+174 -18
View File
@@ -1,15 +1,19 @@
import { useState } from "react";
import { useState, useRef } from "react";
import { downloadLyrics } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
export function 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,
@@ -17,8 +21,10 @@ export function useLyrics() {
artistName: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number
position?: number,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => {
if (!spotifyId) {
toast.error("No Spotify ID found for this track");
@@ -33,33 +39,50 @@ export function useLyrics() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path similar to audio download
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
// 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),
};
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
// For playlist/discography, prepend the folder name
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, 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,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
output_dir: outputDir,
filename_format: settings.filenameFormat,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
use_album_track_number: settings.albumSubfolder,
use_album_track_number: useAlbumTrackNumber,
disc_number: discNumber,
});
if (response.success) {
@@ -87,6 +110,135 @@ export function useLyrics() {
}
};
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 (let i = 0; i < tracksWithSpotifyId.length; i++) {
const track = tracksWithSpotifyId[i];
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__";
// Determine if we should use album track number or sequential position
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
// 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: trackPosition,
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 downloadLyrics({
spotify_id: id,
track_name: track.name,
artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: trackPosition,
use_album_track_number: useAlbumTrackNumber,
disc_number: track.disc_number,
});
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());
@@ -98,7 +250,11 @@ export function useLyrics() {
downloadedLyrics,
failedLyrics,
skippedLyrics,
isBulkDownloadingLyrics,
lyricsDownloadProgress,
handleDownloadLyrics,
handleDownloadAllLyrics,
handleStopLyricsDownload,
resetLyricsState,
};
}
+49 -40
View File
@@ -26,19 +26,6 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
@@ -61,19 +48,6 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
@@ -95,19 +69,6 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
@@ -116,7 +77,7 @@
}
body {
@apply bg-background text-foreground;
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
code, pre, .font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
@@ -164,6 +125,54 @@
@apply text-blue-600;
}
/* Ensure description text uses same color as title */
[data-sonner-toast] [data-description],
[data-sonner-toast] [data-description] * {
opacity: 1 !important;
}
/* Specific color for each toast type - match icon color */
[data-sonner-toast][data-type="success"] [data-description],
[data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 - same as icon */
}
[data-sonner-toast][data-type="error"] [data-description],
[data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 - same as icon */
}
[data-sonner-toast][data-type="warning"] [data-description],
[data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
}
[data-sonner-toast][data-type="info"] [data-description],
[data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
}
/* Dark mode - use same icon colors */
.dark [data-sonner-toast][data-type="success"] [data-description],
.dark [data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 */
}
.dark [data-sonner-toast][data-type="error"] [data-description],
.dark [data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 */
}
.dark [data-sonner-toast][data-type="warning"] [data-description],
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 */
}
.dark [data-sonner-toast][data-type="info"] [data-description],
.dark [data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 */
}
/* Dark mode toast styling */
.dark [data-sonner-toast][data-type="success"] {
@apply bg-green-950 border-green-800 text-green-100;
+10 -1
View File
@@ -5,8 +5,10 @@ import type {
HealthResponse,
LyricsDownloadRequest,
LyricsDownloadResponse,
CoverDownloadRequest,
CoverDownloadResponse,
} from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics } from "../../wailsjs/go/main/App";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
import { main } from "../../wailsjs/go/models";
export async function fetchSpotifyMetadata(
@@ -48,3 +50,10 @@ export async function downloadLyrics(
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);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Format a date to relative time string with max 2 units
* e.g., "23 hours 32 minutes ago", "1 day 14 hours ago"
*/
export function formatRelativeTime(date: Date | string | number): string {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
if (diffMs < 0) return "just now";
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
const remainingMonths = Math.floor((days % 365) / 30);
if (remainingMonths > 0) {
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
}
} else if (months > 0) {
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
const remainingDays = days % 30;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (weeks > 0) {
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
const remainingDays = days % 7;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (days > 0) {
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
const remainingHours = hours % 24;
if (remainingHours > 0) {
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
}
} else if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
const remainingMinutes = minutes % 60;
if (remainingMinutes > 0) {
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
}
} else if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
} else {
return "just now";
}
return "Released " + parts.slice(0, 2).join(" ") + " ago";
}
+181 -9
View File
@@ -1,17 +1,85 @@
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" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
// Filename format presets
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export interface Settings {
downloadPath: string;
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
downloader: "auto" | "tidal" | "qobuz" | "amazon";
theme: string;
themeMode: "auto" | "light" | "dark";
filenameFormat: "title-artist" | "artist-title" | "title";
artistSubfolder: boolean;
albumSubfolder: boolean;
fontFamily: FontFamily;
// New template system
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
filenameTemplate: string;
// Legacy settings (kept for migration)
filenameFormat?: "title-artist" | "artist-title" | "title";
artistSubfolder?: boolean;
albumSubfolder?: boolean;
trackNumber: boolean;
operatingSystem: "Windows" | "linux/MacOS"
sfxEnabled: boolean;
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
// Quality settings for specific sources
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
}
// Folder preset templates
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = {
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
"album-artist": { label: "Album Artist", template: "{album_artist}" },
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
"year": { label: "Year", template: "{year}" },
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
"custom": { label: "Custom...", template: "{artist}/{album}" },
};
// 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}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
// Available template variables
export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
];
// Auto-detect operating system
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
@@ -26,13 +94,47 @@ export const DEFAULT_SETTINGS: Settings = {
downloader: "auto",
theme: "yellow",
themeMode: "auto",
filenameFormat: "title-artist",
artistSubfolder: false,
albumSubfolder: false,
fontFamily: "google-sans",
folderPreset: "none",
folderTemplate: "",
filenamePreset: "title-artist",
filenameTemplate: "{title} - {artist}",
trackNumber: false,
operatingSystem: 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", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
];
export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) {
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.body.style.fontFamily = font.fontFamily;
}
}
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
@@ -55,8 +157,46 @@ export function getSettings(): Settings {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
// Migrate old folder/filename settings to new template system
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
} else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
} else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
} else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
// Always use detected OS (don't persist it)
parsed.operatingSystem = detectOS();
// Set default quality if not present
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
return { ...DEFAULT_SETTINGS, ...parsed };
}
} catch (error) {
@@ -65,6 +205,38 @@ export function getSettings(): Settings {
return DEFAULT_SETTINGS;
}
// Parse template and replace variables with actual values
export interface TemplateData {
artist?: string;
album?: string;
album_artist?: string;
title?: string;
track?: number;
disc?: 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(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist");
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
}
export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = getSettings();
+21
View File
@@ -0,0 +1,21 @@
// Memory cache for spectrum data (fast access, cleared on page refresh)
// Key: file path, Value: spectrum data
const spectrumCache = new Map<string, any>();
export function setSpectrumCache(filePath: string, spectrumData: any): void {
spectrumCache.set(filePath, spectrumData);
}
export function getSpectrumCache(filePath: string): any | null {
return spectrumCache.get(filePath) || null;
}
export function clearSpectrumCache(filePath?: string): void {
if (filePath) {
spectrumCache.delete(filePath);
} else {
spectrumCache.clear();
}
}
+186 -366
View File
@@ -7,456 +7,276 @@ export interface Theme {
};
}
export const themes: Theme[] = [
{
name: "neutral",
label: "Default",
cssVars: {
light: {
// 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)",
primary: "oklch(0.205 0 0)",
"primary-foreground": "oklch(0.985 0 0)",
secondary: "oklch(0.97 0 0)",
"secondary-foreground": "oklch(0.205 0 0)",
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.577 0.245 27.325)",
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)",
"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: {
};
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)",
primary: "oklch(0.922 0 0)",
"primary-foreground": "oklch(0.205 0 0)",
secondary: "oklch(0.269 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.269 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)",
"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: {
};
// Primary colors yang berbeda untuk setiap tema
interface PrimaryColors {
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)",
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: {
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.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)",
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.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)",
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: {
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: {
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: {
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: {
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: {
rose: {
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: {
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: {
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: {
yellow: {
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)",
},
},
};
// 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[] = [
createTheme("amber", "Amber", primaryColors.amber),
createTheme("blue", "Blue", primaryColors.blue),
createTheme("cyan", "Cyan", primaryColors.cyan),
createTheme("emerald", "Emerald", primaryColors.emerald),
createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia),
createTheme("green", "Green", primaryColors.green),
createTheme("indigo", "Indigo", primaryColors.indigo),
createTheme("lime", "Lime", primaryColors.lime),
createTheme("neutral", "Neutral", primaryColors.neutral),
createTheme("orange", "Orange", primaryColors.orange),
createTheme("pink", "Pink", primaryColors.pink),
createTheme("purple", "Purple", primaryColors.purple),
createTheme("red", "Red", primaryColors.red),
createTheme("rose", "Rose", primaryColors.rose),
createTheme("sky", "Sky", primaryColors.sky),
createTheme("teal", "Teal", primaryColors.teal),
createTheme("violet", "Violet", primaryColors.violet),
createTheme("yellow", "Yellow", primaryColors.yellow),
].sort((a, b) => a.name.localeCompare(b.name));
export function applyTheme(themeName: string) {
const theme = themes.find((t) => t.name === themeName) || themes[0];
+9 -5
View File
@@ -6,38 +6,42 @@ import {
playInfoSound,
} from "./audio";
import { logger } from "./logger";
import { getSettings } from "./settings";
const toastStyle = {
className: "font-mono lowercase",
};
// Helper to check if SFX is enabled
const isSfxEnabled = () => getSettings().sfxEnabled;
// Wrapper functions for toast with sound effects
export const toastWithSound = {
success: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.success(msg);
playSuccessSound();
if (isSfxEnabled()) playSuccessSound();
return toast.success(msg, { ...toastStyle, ...data });
},
error: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.error(msg);
playErrorSound();
if (isSfxEnabled()) playErrorSound();
return toast.error(msg, { ...toastStyle, ...data });
},
warning: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.warning(msg);
playWarningSound();
if (isSfxEnabled()) playWarningSound();
return toast.warning(msg, { ...toastStyle, ...data });
},
info: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.info(msg);
playInfoSound();
if (isSfxEnabled()) playInfoSound();
return toast.info(msg, { ...toastStyle, ...data });
},
@@ -45,7 +49,7 @@ export const toastWithSound = {
message: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.info(msg);
playInfoSound();
if (isSfxEnabled()) playInfoSound();
return toast(msg, { ...toastStyle, ...data });
},
};
-4
View File
@@ -3,14 +3,10 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { Toaster } from "@/components/ui/sonner";
import { DebugLogger } from "@/components/DebugLogger";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Toaster position="bottom-left" duration={1000} />
<div className="fixed bottom-2 left-2 z-50">
<DebugLogger />
</div>
</StrictMode>
);
+49 -3
View File
@@ -8,10 +8,13 @@ export interface TrackMetadata {
artists: string;
name: string;
album_name: string;
album_artist?: string;
duration_ms: number;
images: string;
release_date: string;
track_number: number;
total_tracks?: number; // Total tracks in album
disc_number?: number;
external_urls: string;
isrc: string;
album_type?: string;
@@ -109,11 +112,14 @@ export type SpotifyMetadataResponse =
export interface DownloadRequest {
isrc: string;
service: "deezer" | "tidal" | "qobuz" | "amazon";
service: "tidal" | "qobuz" | "amazon";
query?: string;
track_name?: string;
artist_name?: string;
album_name?: string;
album_artist?: string;
release_date?: string;
cover_url?: string; // Spotify cover URL for embedding
api_url?: string;
output_dir?: string;
audio_format?: string;
@@ -123,9 +129,14 @@ export interface DownloadRequest {
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 {
@@ -157,6 +168,7 @@ export interface SpectrumData {
export interface AnalysisResult {
file_path: string;
file_size: number;
sample_rate: number;
channels: number;
bits_per_sample: number;
@@ -173,11 +185,15 @@ export interface LyricsDownloadRequest {
spotify_id: string;
track_name: string;
artist_name: string;
album_name?: string;
album_artist?: string;
release_date?: string;
output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
use_album_track_number?: boolean;
disc_number?: number;
}
export interface LyricsDownloadResponse {
@@ -191,13 +207,43 @@ export interface LyricsDownloadResponse {
export interface TrackAvailability {
spotify_id: string;
tidal: boolean;
deezer: boolean;
amazon: boolean;
qobuz: boolean;
tidal_url?: string;
deezer_url?: string;
amazon_url?: string;
qobuz_url?: string;
}
export interface CoverDownloadRequest {
cover_url: string;
track_name: string;
artist_name: string;
album_name?: string;
album_artist?: string;
release_date?: string;
output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
disc_number?: number;
}
export interface CoverDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
export interface AudioMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
+3 -1
View File
@@ -1,12 +1,14 @@
module spotiflac
go 1.25.4
go 1.25.5
require (
github.com/bogem/id3v2/v2 v2.1.4
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/mewkiz/flac v1.0.13
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0
)
+25
View File
@@ -1,5 +1,7 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
@@ -64,6 +66,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -74,22 +78,43 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-10
View File
@@ -1,10 +0,0 @@
[
"vogel.qqdl.site",
"maus.qqdl.site",
"hund.qqdl.site",
"eu-maus.qqdl.site",
"eu-katze.qqdl.site",
"katze.qqdl.site",
"wolf.qqdl.site",
"tidal.kinoplus.online"
]
-3
View File
@@ -1,3 +0,0 @@
{
"version": "6.5"
}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "6.5"
"productVersion": "7.0"
},
"wailsjsdir": "./frontend",
"assetdir": "./frontend/dist",