Compare commits

...

16 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
52 changed files with 4765 additions and 1463 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
GO_VERSION: '1.25.4' GO_VERSION: '1.25.5'
NODE_VERSION: '24' NODE_VERSION: '24'
jobs: jobs:
+3 -13
View File
@@ -16,22 +16,12 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
## Screenshot ## Screenshot
![Image](https://github.com/user-attachments/assets/ee352566-7e6d-4d6f-9add-d6b55a0187fa) ![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 project ## Other project
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) ### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API 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)
+196 -18
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -124,6 +125,56 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
return string(jsonData), nil 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 // DownloadTrack downloads a track by ISRC
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.ISRC == "" { if req.ISRC == "" {
@@ -140,8 +191,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.OutputDir == "" { if req.OutputDir == "" {
req.OutputDir = "." req.OutputDir = "."
} else { } else {
// Sanitize output directory path to remove invalid characters // Only normalize path separators, don't sanitize user's existing folder names
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir) req.OutputDir = backend.NormalizePath(req.OutputDir)
} }
if req.AudioFormat == "" { if req.AudioFormat == "" {
@@ -185,7 +236,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
// Fallback: if we have track metadata, check if file already exists by filename // Fallback: if we have track metadata, check if file already exists by filename
if req.TrackName != "" && req.ArtistName != "" { 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) expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
@@ -502,11 +553,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_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"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"` UseAlbumTrackNumber bool `json:"use_album_track_number"`
DiscNumber int `json:"disc_number"`
} }
// DownloadLyrics downloads lyrics for a single track // DownloadLyrics downloads lyrics for a single track
@@ -523,11 +578,15 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
SpotifyID: req.SpotifyID, SpotifyID: req.SpotifyID,
TrackName: req.TrackName, TrackName: req.TrackName,
ArtistName: req.ArtistName, ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir, OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat, FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
Position: req.Position, Position: req.Position,
UseAlbumTrackNumber: req.UseAlbumTrackNumber, UseAlbumTrackNumber: req.UseAlbumTrackNumber,
DiscNumber: req.DiscNumber,
} }
resp, err := client.DownloadLyrics(backendReq) resp, err := client.DownloadLyrics(backendReq)
@@ -546,10 +605,14 @@ type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_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"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
DiscNumber int `json:"disc_number"`
} }
// DownloadCover downloads cover art for a single track // DownloadCover downloads cover art for a single track
@@ -566,10 +629,14 @@ func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResp
CoverURL: req.CoverURL, CoverURL: req.CoverURL,
TrackName: req.TrackName, TrackName: req.TrackName,
ArtistName: req.ArtistName, ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir, OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat, FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
Position: req.Position, Position: req.Position,
DiscNumber: req.DiscNumber,
} }
resp, err := client.DownloadCover(backendReq) resp, err := client.DownloadCover(backendReq)
@@ -608,6 +675,11 @@ func (a *App) IsFFmpegInstalled() (bool, error) {
return backend.IsFFmpegInstalled() return backend.IsFFmpegInstalled()
} }
// IsFFprobeInstalled checks if ffprobe is installed
func (a *App) IsFFprobeInstalled() (bool, error) {
return backend.IsFFprobeInstalled()
}
// GetFFmpegPath returns the path to ffmpeg // GetFFmpegPath returns the path to ffmpeg
func (a *App) GetFFmpegPath() (string, error) { func (a *App) GetFFmpegPath() (string, error) {
return backend.GetFFmpegPath() return backend.GetFFmpegPath()
@@ -641,26 +713,12 @@ func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
} }
} }
// InstallFFmpegFromFile installs ffmpeg from a local file path
func (a *App) InstallFFmpegFromFile(filePath string) DownloadFFmpegResponse {
err := backend.InstallFFmpegFromFile(filePath)
if err != nil {
return DownloadFFmpegResponse{
Success: false,
Error: err.Error(),
}
}
return DownloadFFmpegResponse{
Success: true,
Message: "FFmpeg installed successfully from file",
}
}
// ConvertAudioRequest represents a request to convert audio files // ConvertAudioRequest represents a request to convert audio files
type ConvertAudioRequest struct { type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"` InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"` OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"` Bitrate string `json:"bitrate"`
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless)
} }
// ConvertAudio converts audio files using ffmpeg // ConvertAudio converts audio files using ffmpeg
@@ -669,6 +727,7 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul
InputFiles: req.InputFiles, InputFiles: req.InputFiles,
OutputFormat: req.OutputFormat, OutputFormat: req.OutputFormat,
Bitrate: req.Bitrate, Bitrate: req.Bitrate,
Codec: req.Codec,
} }
return backend.ConvertAudio(backendReq) return backend.ConvertAudio(backendReq)
} }
@@ -681,3 +740,122 @@ func (a *App) SelectAudioFiles() ([]string, error) {
} }
return files, nil 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)
}
+19 -1
View File
@@ -382,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) // Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, false) expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename) expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
@@ -403,6 +403,14 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
if spotifyTrackName != "" && spotifyArtistName != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName) safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName) 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 // Build filename based on format settings
var newFilename string var newFilename string
@@ -412,6 +420,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = filenameFormat newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle) newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist) 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 // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
+8
View File
@@ -12,6 +12,7 @@ import (
// AnalysisResult contains the audio analysis data // AnalysisResult contains the audio analysis data
type AnalysisResult struct { type AnalysisResult struct {
FilePath string `json:"file_path"` FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
SampleRate uint32 `json:"sample_rate"` SampleRate uint32 `json:"sample_rate"`
Channels uint8 `json:"channels"` Channels uint8 `json:"channels"`
BitsPerSample uint8 `json:"bits_per_sample"` 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) 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 // Parse FLAC file
f, err := flac.ParseFile(filepath) f, err := flac.ParseFile(filepath)
if err != nil { if err != nil {
@@ -38,6 +45,7 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
result := &AnalysisResult{ result := &AnalysisResult{
FilePath: filepath, FilePath: filepath,
FileSize: fileInfo.Size(),
} }
// Extract basic audio properties from STREAMINFO block // Extract basic audio properties from STREAMINFO block
+25 -3
View File
@@ -22,10 +22,14 @@ type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_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"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
DiscNumber int `json:"disc_number"`
} }
// CoverDownloadResponse represents the response from cover download // CoverDownloadResponse represents the response from cover download
@@ -50,9 +54,17 @@ func NewCoverClient() *CoverClient {
} }
// buildCoverFilename builds the cover filename based on settings (same as track filename) // buildCoverFilename builds the cover filename based on settings (same as track filename)
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string { func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) 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 var filename string
@@ -61,6 +73,16 @@ func buildCoverFilename(trackName, artistName, filenameFormat string, includeTra
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) 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 // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
@@ -161,7 +183,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
if outputDir == "" { if outputDir == "" {
outputDir = GetDefaultMusicPath() outputDir = GetDefaultMusicPath()
} else { } else {
outputDir = SanitizeFolderPath(outputDir) outputDir = NormalizePath(outputDir)
} }
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -176,7 +198,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
if filenameFormat == "" { if filenameFormat == "" {
filenameFormat = "title-artist" // default filenameFormat = "title-artist" // default
} }
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position) filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename) filePath := filepath.Join(outputDir, filename)
// Check if file already exists // Check if file already exists
+221 -163
View File
@@ -13,7 +13,6 @@ import (
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
"time"
"github.com/ulikunitz/xz" "github.com/ulikunitz/xz"
) )
@@ -30,7 +29,8 @@ func decodeBase64(encoded string) (string, error) {
const ( const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA==" ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6" ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZm1wZWcvemlw" ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
) )
// GetFFmpegDir returns the directory where ffmpeg should be stored // GetFFmpegDir returns the directory where ffmpeg should be stored
@@ -57,6 +57,40 @@ func GetFFmpegPath() (string, error) {
return filepath.Join(ffmpegDir, ffmpegName), nil 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 // IsFFmpegInstalled checks if ffmpeg is installed in the app directory
func IsFFmpegInstalled() (bool, error) { func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath() ffmpegPath, err := GetFFmpegPath()
@@ -92,15 +126,49 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return fmt.Errorf("failed to create ffmpeg directory: %w", err) return fmt.Errorf("failed to create ffmpeg directory: %w", err)
} }
// Get the appropriate URL for the current OS // 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 var encodedURL string
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
encodedURL = ffmpegWindowsURL encodedURL = ffmpegWindowsURL
case "linux": case "linux":
encodedURL = ffmpegLinuxURL encodedURL = ffmpegLinuxURL
case "darwin":
encodedURL = ffmpegMacOSURL
default: default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
} }
@@ -113,6 +181,15 @@ func DownloadFFmpeg(progressCallback func(int)) error {
fmt.Printf("[FFmpeg] Downloading from: %s\n", url) 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 // Create temporary file for download
tmpFile, err := os.CreateTemp("", "ffmpeg-*") tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil { if err != nil {
@@ -124,12 +201,12 @@ func DownloadFFmpeg(progressCallback func(int)) error {
// Download the file // Download the file
resp, err := http.Get(url) resp, err := http.Get(url)
if err != nil { if err != nil {
return fmt.Errorf("failed to download ffmpeg: %w", err) return fmt.Errorf("failed to download: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download ffmpeg: HTTP %d", resp.StatusCode) return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode)
} }
totalSize := resp.ContentLength totalSize := resp.ContentLength
@@ -146,8 +223,10 @@ func DownloadFFmpeg(progressCallback func(int)) error {
} }
downloaded += int64(n) downloaded += int64(n)
if totalSize > 0 && progressCallback != nil { if totalSize > 0 && progressCallback != nil {
progress := int(float64(downloaded) / float64(totalSize) * 100) // Scale progress between progressStart and progressEnd
progressCallback(progress) rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
} }
} }
if err == io.EOF { if err == io.EOF {
@@ -162,18 +241,14 @@ func DownloadFFmpeg(progressCallback func(int)) error {
fmt.Printf("[FFmpeg] Download complete, extracting...\n") fmt.Printf("[FFmpeg] Download complete, extracting...\n")
// Extract the archive // Extract the archive based on file type
switch runtime.GOOS { if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
case "windows", "darwin": return extractTarXz(tmpFile.Name(), destDir)
return extractZip(tmpFile.Name(), ffmpegDir)
case "linux":
return extractTarXz(tmpFile.Name(), ffmpegDir)
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
} }
return extractZip(tmpFile.Name(), destDir)
} }
// extractZip extracts ffmpeg from a zip archive // extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
func extractZip(zipPath, destDir string) error { func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath) r, err := zip.OpenReader(zipPath)
if err != nil { if err != nil {
@@ -182,44 +257,73 @@ func extractZip(zipPath, destDir string) error {
defer r.Close() defer r.Close()
ffmpegName := "ffmpeg" ffmpegName := "ffmpeg"
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe" ffmpegName = "ffmpeg.exe"
ffprobeName = "ffprobe.exe"
} }
destPath := filepath.Join(destDir, ffmpegName) foundFFmpeg := false
foundFFprobe := false
for _, f := range r.File { for _, f := range r.File {
// Look for ffmpeg executable in any subdirectory
baseName := filepath.Base(f.Name) baseName := filepath.Base(f.Name)
if baseName == ffmpegName && !f.FileInfo().IsDir() { if f.FileInfo().IsDir() {
fmt.Printf("[FFmpeg] Found: %s\n", f.Name) continue
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open file in zip: %w", err)
}
defer rc.Close()
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
_, err = io.Copy(outFile, rc)
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
return nil
} }
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)
} }
return fmt.Errorf("ffmpeg executable not found in archive") // 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 from a tar.xz archive // extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
func extractTarXz(tarXzPath, destDir string) error { func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath) file, err := os.Open(tarXzPath)
if err != nil { if err != nil {
@@ -235,7 +339,9 @@ func extractTarXz(tarXzPath, destDir string) error {
tarReader := tar.NewReader(xzReader) tarReader := tar.NewReader(xzReader)
ffmpegName := "ffmpeg" ffmpegName := "ffmpeg"
destPath := filepath.Join(destDir, ffmpegName) ffprobeName := "ffprobe"
foundFFmpeg := false
foundFFprobe := false
for { for {
header, err := tarReader.Next() header, err := tarReader.Next()
@@ -246,34 +352,62 @@ func extractTarXz(tarXzPath, destDir string) error {
return fmt.Errorf("failed to read tar: %w", err) return fmt.Errorf("failed to read tar: %w", err)
} }
baseName := filepath.Base(header.Name) if header.Typeflag != tar.TypeReg {
if baseName == ffmpegName && header.Typeflag == tar.TypeReg { 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)
}
defer outFile.Close()
_, err = io.Copy(outFile, tarReader)
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
return nil
} }
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)
} }
return fmt.Errorf("ffmpeg executable not found in archive") // 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 // ConvertAudioRequest represents a request to convert audio files
type ConvertAudioRequest struct { type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"` InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"` // mp3, m4a OutputFormat string `json:"output_format"` // mp3, m4a
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" 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 // ConvertAudioResult represents the result of a single file conversion
@@ -348,7 +482,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
// Extract cover art and lyrics from input file before conversion // Extract cover art and lyrics from input file before conversion
var coverArtPath string var coverArtPath string
var lyrics string var lyrics string
coverArtPath, _ = ExtractCoverArt(inputFile) coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(inputFile) lyrics, err = ExtractLyrics(inputFile)
if err != nil { if err != nil {
@@ -378,12 +512,28 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
// Map video stream if exists (for cover art) // Map video stream if exists (for cover art)
args = append(args, "-map", "0:v?", "-c:v", "copy") args = append(args, "-map", "0:v?", "-c:v", "copy")
case "m4a": case "m4a":
args = append(args, // Determine codec: ALAC (lossless) or AAC (lossy)
"-codec:a", "aac", codec := req.Codec
"-b:a", req.Bitrate, if codec == "" {
"-map", "0:a", // Map audio stream codec = "aac" // Default to AAC for backward compatibility
"-map_metadata", "0", // Copy all metadata }
)
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 // 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, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
} }
@@ -463,95 +613,3 @@ func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
Size: info.Size(), Size: info.Size(),
}, nil }, nil
} }
// InstallFFmpegFromFile installs ffmpeg from a local file path
func InstallFFmpegFromFile(filePath string) error {
// Check if file exists
info, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("file does not exist: %w", err)
}
// Check if it's a regular file (not a directory)
if info.IsDir() {
return fmt.Errorf("path is a directory, not a file")
}
// Verify it's likely an ffmpeg executable by checking the filename
fileName := strings.ToLower(filepath.Base(filePath))
expectedName := "ffmpeg"
if runtime.GOOS == "windows" {
expectedName = "ffmpeg.exe"
}
if fileName != expectedName && !strings.Contains(fileName, "ffmpeg") {
return fmt.Errorf("file does not appear to be an ffmpeg executable (expected name containing 'ffmpeg')")
}
// Get destination path
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("failed to get ffmpeg path: %w", err)
}
ffmpegDir := filepath.Dir(ffmpegPath)
// Create directory if it doesn't exist
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
// Copy file to destination
sourceFile, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
destFile, err := os.OpenFile(ffmpegPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
sourceFile.Close()
return fmt.Errorf("failed to create destination file: %w", err)
}
_, err = io.Copy(destFile, sourceFile)
sourceFile.Close()
if err != nil {
destFile.Close()
return fmt.Errorf("failed to copy file: %w", err)
}
// Ensure all data is written to disk
if err := destFile.Sync(); err != nil {
destFile.Close()
return fmt.Errorf("failed to sync file: %w", err)
}
destFile.Close()
// On Windows, file may still be locked by antivirus or system
// Wait a bit and retry verification
maxRetries := 3
retryDelay := 500 * time.Millisecond
var verifyErr error
for i := 0; i < maxRetries; i++ {
if i > 0 {
time.Sleep(retryDelay)
}
cmd := exec.Command(ffmpegPath, "-version")
// Hide console window on Windows
setHideWindow(cmd)
verifyErr = cmd.Run()
if verifyErr == nil {
break
}
}
if verifyErr != nil {
return fmt.Errorf("file copied but ffmpeg verification failed after %d attempts: %w", maxRetries, verifyErr)
}
fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath)
return nil
}
+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
}
+41 -15
View File
@@ -10,10 +10,18 @@ import (
) )
// BuildExpectedFilename builds the expected filename based on track metadata and settings // 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 // Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) 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 var filename string
@@ -22,6 +30,16 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) 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 // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
@@ -56,11 +74,11 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
func sanitizeFilename(name string) string { func sanitizeFilename(name string) string {
// Replace forward slash with space (more natural than underscore) // Replace forward slash with space (more natural than underscore)
sanitized := strings.ReplaceAll(name, "/", " ") sanitized := strings.ReplaceAll(name, "/", " ")
// Remove other invalid filesystem characters (replace with space) // Remove other invalid filesystem characters (replace with space)
re := regexp.MustCompile(`[<>:"\\|?*]`) re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ") sanitized = re.ReplaceAllString(sanitized, " ")
// Remove control characters (0x00-0x1F, 0x7F) // Remove control characters (0x00-0x1F, 0x7F)
var result strings.Builder var result strings.Builder
for _, r := range sanitized { for _, r := range sanitized {
@@ -79,49 +97,57 @@ func sanitizeFilename(name string) string {
} }
// Remove emoji ranges (most emoji are in these ranges) // Remove emoji ranges (most emoji are in these ranges)
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols (r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
(r >= 0x2700 && r <= 0x27BF) || // Dingbats (r >= 0x2700 && r <= 0x27BF) || // Dingbats
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors (r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs (r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons (r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols (r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags) (r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
continue continue
} }
result.WriteRune(r) result.WriteRune(r)
} }
sanitized = result.String() sanitized = result.String()
sanitized = strings.TrimSpace(sanitized) sanitized = strings.TrimSpace(sanitized)
// Remove leading/trailing dots and spaces (Windows doesn't allow these) // Remove leading/trailing dots and spaces (Windows doesn't allow these)
sanitized = strings.Trim(sanitized, ". ") sanitized = strings.Trim(sanitized, ". ")
// Normalize consecutive spaces to single space // Normalize consecutive spaces to single space
re = regexp.MustCompile(`\s+`) re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ") sanitized = re.ReplaceAllString(sanitized, " ")
// Normalize consecutive underscores to single underscore // Normalize consecutive underscores to single underscore
re = regexp.MustCompile(`_+`) re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_") sanitized = re.ReplaceAllString(sanitized, "_")
// Remove leading/trailing underscores and spaces // Remove leading/trailing underscores and spaces
sanitized = strings.Trim(sanitized, "_ ") sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" { if sanitized == "" {
return "Unknown" return "Unknown"
} }
// Ensure the result is valid UTF-8 // Ensure the result is valid UTF-8
if !utf8.ValidString(sanitized) { if !utf8.ValidString(sanitized) {
// If invalid UTF-8, try to fix it // If invalid UTF-8, try to fix it
sanitized = strings.ToValidUTF8(sanitized, "_") sanitized = strings.ToValidUTF8(sanitized, "_")
} }
return 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 // 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 { func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows // Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
+35 -14
View File
@@ -16,15 +16,15 @@ import (
// LRCLibResponse represents the LRCLIB API response // LRCLibResponse represents the LRCLIB API response
type LRCLibResponse struct { type LRCLibResponse struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
TrackName string `json:"trackName"` TrackName string `json:"trackName"`
ArtistName string `json:"artistName"` ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"` AlbumName string `json:"albumName"`
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
Instrumental bool `json:"instrumental"` Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"` PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"` SyncedLyrics string `json:"syncedLyrics"`
} }
// LyricsLine represents a single line of lyrics // LyricsLine represents a single line of lyrics
@@ -46,11 +46,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_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"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"` UseAlbumTrackNumber bool `json:"use_album_track_number"`
DiscNumber int `json:"disc_number"`
} }
// LyricsDownloadResponse represents the response from lyrics download // LyricsDownloadResponse represents the response from lyrics download
@@ -255,7 +259,7 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
simplifiedTrack := simplifyTrackName(trackName) simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack) fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName) resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil return resp, "LRCLIB (simplified)", nil
@@ -270,7 +274,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return nil, "", fmt.Errorf("lyrics not found in any source") return nil, "", fmt.Errorf("lyrics not found in any source")
} }
// ConvertToLRC converts lyrics response to LRC format // ConvertToLRC converts lyrics response to LRC format
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string { func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder var sb strings.Builder
@@ -309,9 +312,17 @@ func msToLRCTimestamp(msStr string) string {
} }
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename) // 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) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) 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 var filename string
@@ -320,6 +331,16 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) 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 // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
@@ -364,7 +385,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
if outputDir == "" { if outputDir == "" {
outputDir = GetDefaultMusicPath() outputDir = GetDefaultMusicPath()
} else { } else {
outputDir = SanitizeFolderPath(outputDir) outputDir = NormalizePath(outputDir)
} }
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -379,7 +400,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
if filenameFormat == "" { if filenameFormat == "" {
filenameFormat = "title-artist" // default 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) filePath := filepath.Join(outputDir, filename)
// Check if file already exists // Check if file already exists
+84
View File
@@ -7,6 +7,7 @@ import (
pathfilepath "path/filepath" pathfilepath "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
id3v2 "github.com/bogem/id3v2/v2" id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture"
@@ -600,3 +601,86 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext) 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
}
+20 -2
View File
@@ -263,7 +263,7 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err return err
} }
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, 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 var filename string
// Determine track number to use // Determine track number to use
@@ -272,11 +272,27 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
numberToUse = trackNumber 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 {}) // Check if format is a template (contains {})
if strings.Contains(format, "{") { if strings.Contains(format, "{") {
filename = format filename = format
filename = strings.ReplaceAll(filename, "{title}", title) filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist) 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 // Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 { if numberToUse > 0 {
@@ -355,6 +371,8 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists) safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle) safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC) // Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists { if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
@@ -363,7 +381,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
} }
// Build filename based on format settings (use Spotify track number) // Build filename based on format settings (use Spotify track number)
filename := buildQobuzFilename(safeTitle, safeArtist, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename) filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
+359 -210
View File
@@ -2,11 +2,7 @@ package backend
import ( import (
"context" "context"
"crypto/hmac" "encoding/base64"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -14,8 +10,6 @@ import (
"math/rand" "math/rand"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -23,13 +17,12 @@ import (
) )
const ( const (
spotifyTokenURL = "https://open.spotify.com/api/token" spotifyTokenURL = "https://accounts.spotify.com/api/token"
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
albumBaseURL = "https://api.spotify.com/v1/albums/%s" albumBaseURL = "https://api.spotify.com/v1/albums/%s"
trackBaseURL = "https://api.spotify.com/v1/tracks/%s" trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s" artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums" artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
secretBytesRemotePath = "https://cdn.jsdelivr.net/gh/afkarxyz/secretBytes@refs/heads/main/secrets/secretBytes.json"
) )
var ( var (
@@ -38,18 +31,37 @@ var (
// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API. // SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API.
type SpotifyMetadataClient struct { type SpotifyMetadataClient struct {
httpClient *http.Client httpClient *http.Client
rng *rand.Rand clientID string
rngMu sync.Mutex clientSecret string
userAgent 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 { func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano()) 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{ c := &SpotifyMetadataClient{
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
rng: rand.New(src), clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
} }
c.userAgent = c.randomUserAgent() c.userAgent = c.randomUserAgent()
return c return c
@@ -187,17 +199,10 @@ type spotifyURI struct {
DiscographyGroup string DiscographyGroup string
} }
type secretEntry struct {
Version int `json:"version"`
Secret []int `json:"secret"`
}
type serverTimeResponse struct {
ServerTime int64 `json:"serverTime"`
}
type accessTokenResponse struct { 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 { type image struct {
@@ -352,7 +357,9 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
case "artist_discography": case "artist_discography":
return c.fetchArtistDiscography(ctx, parsed, token, batch, delay) return c.fetchArtistDiscography(ctx, parsed, token, batch, delay)
case "artist": 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: default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
} }
@@ -859,211 +866,58 @@ func (c *SpotifyMetadataClient) randRange(min, max int) int {
} }
func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) { 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 { if err != nil {
return "", err return "", err
} }
timestampMS := time.Now().UnixMilli() // Set Basic Auth header
params := url.Values{} req.SetBasicAuth(c.clientID, c.clientSecret)
params.Set("reason", "init") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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()
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil { if err != nil {
return "", err return "", err
} }
if resp.StatusCode != http.StatusOK { 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 var token accessTokenResponse
if err := json.Unmarshal(body, &token); err != nil { if err := json.Unmarshal(body, &token); err != nil {
return "", err return "", err
} }
if token.AccessToken == "" { if token.AccessToken == "" {
return "", errors.New("failed to get access token: empty token received") 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 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) { func parseSpotifyURI(input string) (spotifyURI, error) {
trimmed := strings.TrimSpace(input) trimmed := strings.TrimSpace(input)
if trimmed == "" { if trimmed == "" {
@@ -1239,3 +1093,298 @@ func maxInt(a, b int) int {
} }
return b 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)
}
+45 -37
View File
@@ -128,41 +128,25 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
} }
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
// Decode base64 API URL // Hardcoded API URLs (base64 encoded for obfuscation)
apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==") encodedAPIs := []string{
"dm9nZWwucXFkbC5zaXRl", // API 1
// Add cache-busting parameter with current timestamp "bWF1cy5xcWRsLnNpdGU=", // API 2
urlWithCacheBust := fmt.Sprintf("%s?t=%d", string(apiURL), time.Now().Unix()) "aHVuZC5xcWRsLnNpdGU=", // API 3
"a2F0emUucXFkbC5zaXRl", // API 4
// Create request with cache bypass headers "d29sZi5xcWRsLnNpdGU=", // API 5
req, err := http.NewRequest("GET", urlWithCacheBust, nil) "dGlkYWwua2lub3BsdXMub25saW5l", // API 6
if err != nil { "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7
return nil, fmt.Errorf("failed to create request: %w", err) "dHJpdG9uLnNxdWlkLnd0Zg==", // API 8
}
// Add headers to bypass cache
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Expires", "0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch API list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to fetch API list: HTTP %d", resp.StatusCode)
}
var apiList []string
if err := json.NewDecoder(resp.Body).Decode(&apiList); err != nil {
return nil, fmt.Errorf("failed to decode API list: %w", err)
} }
var apis []string var apis []string
for _, api := range apiList { for _, encoded := range encodedAPIs {
apis = append(apis, "https://"+api) decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
continue
}
apis = append(apis, "https://"+string(decoded))
} }
return apis, nil return apis, nil
@@ -834,6 +818,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
artistNameForFile := sanitizeFilename(artistName) artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle) trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists // Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
@@ -842,7 +828,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
} }
// Build filename based on format settings (use sanitized versions for filename) // Build filename based on format settings (use sanitized versions for filename)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -947,6 +933,8 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
artistNameForFile := sanitizeFilename(artistName) artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle) trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists // Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
@@ -954,7 +942,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "EXISTS:" + existingFile, nil return "EXISTS:" + existingFile, nil
} }
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -1081,6 +1069,8 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
finalArtistNameForFile := sanitizeFilename(finalArtistName) finalArtistNameForFile := sanitizeFilename(finalArtistName)
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
finalAlbumArtistForFile := sanitizeFilename(albumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC) // Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
@@ -1089,7 +1079,7 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
} }
// Build filename // Build filename
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, finalAlbumTitleForFile, finalAlbumArtistForFile, releaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -1405,6 +1395,8 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
finalArtistNameForFile := sanitizeFilename(finalArtistName) finalArtistNameForFile := sanitizeFilename(finalArtistName)
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
finalAlbumArtistForFile := sanitizeFilename(albumArtist)
// Check if file already exists (use Spotify ISRC) // Check if file already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
@@ -1412,7 +1404,7 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
return "EXISTS:" + existingFile, nil return "EXISTS:" + existingFile, nil
} }
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, finalAlbumTitleForFile, finalAlbumArtistForFile, releaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -1511,7 +1503,7 @@ func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISR
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC)
} }
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string var filename string
// Determine track number to use // Determine track number to use
@@ -1520,11 +1512,27 @@ func buildTidalFilename(title, artist string, trackNumber int, format string, in
numberToUse = trackNumber 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 {}) // Check if format is a template (contains {})
if strings.Contains(format, "{") { if strings.Contains(format, "{") {
filename = format filename = format
filename = strings.ReplaceAll(filename, "{title}", title) filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist) 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 // Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 { if numberToUse > 0 {
+3 -1
View File
@@ -18,5 +18,7 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> <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> <title>SpotiFLAC</title>
</head> </head>
<body> <body>
+6 -8
View File
@@ -17,19 +17,17 @@
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.561.0", "lucide-react": "^0.562.0",
"motion": "^12.23.26",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -39,18 +37,18 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/node": "^25.0.2", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0", "globals": "^16.5.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.49.0", "typescript-eslint": "^8.50.1",
"vite": "^7.2.7" "vite": "^7.3.0"
} }
} }
+1 -1
View File
@@ -1 +1 @@
d4b3974abd992c8ff941c6fde9f62062 0f9764c2a4597a75120d3e76c32af7a9
+370 -409
View File
File diff suppressed because it is too large Load Diff
+75 -23
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -9,9 +9,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search, X } from "lucide-react"; import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App"; import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -29,6 +29,7 @@ import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { DownloadProgressToast } from "@/components/DownloadProgressToast";
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { AudioConverterPage } from "@/components/AudioConverterPage"; import { AudioConverterPage } from "@/components/AudioConverterPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage"; import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import type { HistoryItem } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory";
@@ -54,9 +55,11 @@ function App() {
const [hasUpdate, setHasUpdate] = useState(false); const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null); const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.8"; const CURRENT_VERSION = "7.0";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
@@ -67,10 +70,19 @@ function App() {
useEffect(() => { useEffect(() => {
const settings = getSettings(); const initSettings = async () => {
applyThemeMode(settings.themeMode); const settings = getSettings();
applyTheme(settings.theme); applyThemeMode(settings.themeMode);
applyFont(settings.fontFamily); 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 mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => { const handleChange = () => {
@@ -85,11 +97,22 @@ function App() {
checkForUpdates(); checkForUpdates();
loadHistory(); loadHistory();
// Scroll listener for jump to top button
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => { return () => {
mediaQuery.removeEventListener("change", handleChange); mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("scroll", handleScroll);
}; };
}, []); }, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
useEffect(() => { useEffect(() => {
setSelectedTracks([]); setSelectedTracks([]);
setSearchQuery(""); setSearchQuery("");
@@ -281,10 +304,17 @@ function App() {
checkingAvailability={availability.checkingTrackId === track.spotify_id} checkingAvailability={availability.checkingTrackId === track.spotify_id}
availability={availability.getAvailability(track.spotify_id || "")} availability={availability.getAvailability(track.spotify_id || "")}
downloadingCover={cover.downloadingCover} 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} 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} onCheckAvailability={availability.checkAvailability}
onDownloadCover={cover.handleDownloadCover} 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} onOpenFolder={handleOpenFolder}
/> />
); );
@@ -326,11 +356,11 @@ function App() {
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
@@ -394,11 +424,11 @@ function App() {
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)}
@@ -468,11 +498,11 @@ function App() {
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)}
@@ -515,6 +545,8 @@ function App() {
return <AudioAnalysisPage />; return <AudioAnalysisPage />;
case "audio-converter": case "audio-converter":
return <AudioConverterPage />; return <AudioConverterPage />;
case "file-manager":
return <FileManagerPage />;
default: default:
return ( return (
<> <>
@@ -626,13 +658,22 @@ function App() {
loading={metadata.loading} loading={metadata.loading}
onUrlChange={setSpotifyUrl} onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata} onFetch={handleFetchMetadata}
onFetchUrl={async (url) => {
setSpotifyUrl(url);
const updatedUrl = await metadata.handleFetchMetadata(url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
}}
history={fetchHistory} history={fetchHistory}
onHistorySelect={handleHistorySelect} onHistorySelect={handleHistorySelect}
onHistoryRemove={removeFromHistory} onHistoryRemove={removeFromHistory}
hasResult={!!metadata.metadata} hasResult={!!metadata.metadata}
searchMode={isSearchMode}
onSearchModeChange={setIsSearchMode}
/> />
{metadata.metadata && renderMetadata()} {!isSearchMode && metadata.metadata && renderMetadata()}
</> </>
); );
} }
@@ -659,6 +700,17 @@ function App() {
isOpen={downloadQueue.isOpen} isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue} 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>
</TooltipProvider> </TooltipProvider>
); );
+5 -3
View File
@@ -52,8 +52,8 @@ interface AlbumInfoProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: 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) => 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) => 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; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void; onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void; onDownloadAllCovers?: () => void;
@@ -148,7 +148,9 @@ export function AlbumInfo({
<span></span> <span></span>
<span>{albumInfo.release_date}</span> <span>{albumInfo.release_date}</span>
<span></span> <span></span>
<span>{albumInfo.total_tracks} songs</span> <span>
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
</span>
</div> </div>
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
+2 -2
View File
@@ -57,8 +57,8 @@ interface ArtistInfoProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: 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) => 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) => 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; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void; onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void; onDownloadAllCovers?: () => void;
+19 -3
View File
@@ -8,7 +8,8 @@ import {
TrendingUp, TrendingUp,
FileAudio, FileAudio,
Clock, Clock,
Gauge Gauge,
HardDrive
} from "lucide-react"; } from "lucide-react";
import type { AnalysisResult } from "@/types/api"; import type { AnalysisResult } from "@/types/api";
@@ -78,14 +79,22 @@ export function AudioAnalysis({
return num.toFixed(2); 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) // Calculate Nyquist frequency (half of sample rate)
const nyquistFreq = result.sample_rate / 2; const nyquistFreq = result.sample_rate / 2;
return ( return (
<Card> <Card className="gap-2">
<CardHeader> <CardHeader>
{filePath && ( {filePath && (
<p className="text-sm font-mono truncate">{filePath}</p> <p className="text-sm font-mono break-all">{filePath}</p>
)} )}
</CardHeader> </CardHeader>
@@ -117,6 +126,13 @@ export function AudioAnalysis({
<span className="text-muted-foreground">Nyquist:</span> <span className="text-muted-foreground">Nyquist:</span>
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span> <span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
</div> </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>
{/* Dynamic Range - Single line */} {/* Dynamic Range - Single line */}
+115 -118
View File
@@ -19,7 +19,6 @@ import { Spinner } from "@/components/ui/spinner";
import { import {
IsFFmpegInstalled, IsFFmpegInstalled,
DownloadFFmpeg, DownloadFFmpeg,
InstallFFmpegFromFile,
ConvertAudio, ConvertAudio,
SelectAudioFiles, SelectAudioFiles,
} from "../../wailsjs/go/main/App"; } from "../../wailsjs/go/main/App";
@@ -30,11 +29,20 @@ interface AudioFile {
path: string; path: string;
name: string; name: string;
format: string; format: string;
size: number;
status: "pending" | "converting" | "success" | "error"; status: "pending" | "converting" | "success" | "error";
error?: string; error?: string;
outputPath?: 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 = [ const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" }, { value: "320k", label: "320k" },
{ value: "256k", label: "256k" }, { value: "256k", label: "256k" },
@@ -42,6 +50,11 @@ const BITRATE_OPTIONS = [
{ value: "128k", label: "128k" }, { value: "128k", label: "128k" },
]; ];
const M4A_CODEC_OPTIONS = [
{ value: "aac", label: "AAC" },
{ value: "alac", label: "ALAC" },
];
const STORAGE_KEY = "spotiflac_audio_converter_state"; const STORAGE_KEY = "spotiflac_audio_converter_state";
export function AudioConverterPage() { export function AudioConverterPage() {
@@ -90,13 +103,26 @@ export function AudioConverterPage() {
} }
return "320k"; 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 [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
// Helper function to save state to sessionStorage // Helper function to save state to sessionStorage
const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string }) => { const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string; m4aCodec: "aac" | "alac" }) => {
try { try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (err) { } catch (err) {
@@ -109,10 +135,10 @@ export function AudioConverterPage() {
checkFfmpegInstallation(); checkFfmpegInstallation();
}, []); }, []);
// Save state to sessionStorage whenever files, outputFormat, or bitrate changes // Save state to sessionStorage whenever files, outputFormat, bitrate, or m4aCodec changes
useEffect(() => { useEffect(() => {
saveState({ files, outputFormat, bitrate }); saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, saveState]); }, [files, outputFormat, bitrate, m4aCodec, saveState]);
// Auto-set output format to M4A if all files are MP3 // Auto-set output format to M4A if all files are MP3
useEffect(() => { useEffect(() => {
@@ -122,10 +148,19 @@ export function AudioConverterPage() {
if (allMP3 && outputFormat !== "m4a") { if (allMP3 && outputFormat !== "m4a") {
setOutputFormat("m4a"); setOutputFormat("m4a");
} }
}, [files, outputFormat]);
// 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) // Check if format selection should be disabled (all files are MP3)
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "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 // Detect fullscreen/maximized window
useEffect(() => { useEffect(() => {
@@ -181,61 +216,6 @@ export function AudioConverterPage() {
} }
}; };
const handleFFmpegFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
setIsDraggingFFmpeg(false);
if (paths.length === 0) return;
// Only process the first file
const filePath = paths[0];
const fileName = filePath.split(/[/\\]/).pop()?.toLowerCase() || "";
// Check if it's likely an ffmpeg executable
if (!fileName.includes("ffmpeg")) {
toast.error("Invalid File", {
description: "Please drop an FFmpeg executable file",
});
return;
}
setInstallingFfmpeg(true);
try {
const result = await InstallFFmpegFromFile(filePath);
if (result.success) {
toast.success("FFmpeg Installed", {
description: "FFmpeg has been installed successfully from file",
});
setFfmpegInstalled(true);
} else {
toast.error("Installation Failed", {
description: result.error || "Failed to install FFmpeg",
});
}
} catch (err) {
toast.error("Installation Failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setInstallingFfmpeg(false);
}
},
[]
);
useEffect(() => {
if (ffmpegInstalled === false) {
// Set up drag and drop for FFmpeg installation
OnFileDrop((x, y, paths) => {
handleFFmpegFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}
}, [ffmpegInstalled, handleFFmpegFileDrop]);
const handleSelectFiles = async () => { const handleSelectFiles = async () => {
try { try {
const selectedFiles = await SelectAudioFiles(); const selectedFiles = await SelectAudioFiles();
@@ -249,7 +229,7 @@ export function AudioConverterPage() {
} }
}; };
const addFiles = useCallback((paths: string[]) => { const addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"]; const validExtensions = [".mp3", ".flac"];
// Check for M4A files specifically // Check for M4A files specifically
@@ -264,12 +244,19 @@ export function AudioConverterPage() {
}); });
} }
// 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) => { setFiles((prev) => {
const newFiles: AudioFile[] = paths const newFiles: AudioFile[] = validPaths
.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
})
.filter((path) => !prev.some((f) => f.path === path)) .filter((path) => !prev.some((f) => f.path === path))
.map((path) => { .map((path) => {
const name = path.split(/[/\\]/).pop() || path; const name = path.split(/[/\\]/).pop() || path;
@@ -278,6 +265,7 @@ export function AudioConverterPage() {
path, path,
name, name,
format: ext, format: ext,
size: fileSizes[path] || 0,
status: "pending" as const, status: "pending" as const,
}; };
}); });
@@ -364,6 +352,7 @@ export function AudioConverterPage() {
input_files: inputPaths, input_files: inputPaths,
output_format: outputFormat, output_format: outputFormat,
bitrate: bitrate, bitrate: bitrate,
codec: outputFormat === "m4a" ? m4aCodec : "",
}); });
// Update file statuses based on results // Update file statuses based on results
@@ -434,35 +423,13 @@ export function AudioConverterPage() {
<div <div
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${ className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]" isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
} ${ } border-muted-foreground/30`}
isDraggingFFmpeg
? "border-primary bg-primary/10"
: "border-muted-foreground/30"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDraggingFFmpeg(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDraggingFFmpeg(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDraggingFFmpeg(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
> >
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Download className="h-8 w-8 text-muted-foreground" /> <Download className="h-8 w-8 text-primary" />
</div> </div>
<p className="text-sm text-muted-foreground mb-2 text-center">
FFmpeg is required to convert audio files.
</p>
<p className="text-sm text-muted-foreground mb-4 text-center"> <p className="text-sm text-muted-foreground mb-4 text-center">
{isDraggingFFmpeg FFmpeg is required to convert audio files
? "Drop your FFmpeg executable here"
: "Drag and drop your FFmpeg executable here, or click the button below to download automatically."}
</p> </p>
<Button <Button
onClick={handleInstallFfmpeg} onClick={handleInstallFfmpeg}
@@ -538,18 +505,18 @@ export function AudioConverterPage() {
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted"> <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" /> <Upload className="h-8 w-8 text-primary" />
</div> </div>
<p className="text-sm text-muted-foreground mb-2 text-center"> <p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging {isDragging
? "Drop your audio files here" ? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"} : "Drag and drop audio files here, or click the button below to select"}
</p> </p>
<p className="text-xs text-muted-foreground mb-4 text-center">
Supported formats: FLAC, MP3
</p>
<Button onClick={handleSelectFiles} size="lg"> <Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5" /> <Upload className="h-5 w-5" />
Select Files Select Files
</Button> </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"> <div className="w-full h-full p-6 space-y-4 flex flex-col">
@@ -578,27 +545,54 @@ export function AudioConverterPage() {
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
</div> </div>
<div className="flex items-center gap-2"> {/* Codec selection for M4A - only show ALAC option when input has FLAC files */}
<Label className="whitespace-nowrap">Bitrate:</Label> {outputFormat === "m4a" && hasFlacFiles && (
<ToggleGroup <div className="flex items-center gap-2">
type="single" <Label className="whitespace-nowrap">Codec:</Label>
variant="outline" <ToggleGroup
value={bitrate} type="single"
onValueChange={(value) => { variant="outline"
if (value) setBitrate(value); value={m4aCodec}
}} onValueChange={(value) => {
> if (value) setM4aCodec(value as "aac" | "alac");
{BITRATE_OPTIONS.map((option) => ( }}
<ToggleGroupItem >
key={option.value} {M4A_CODEC_OPTIONS.map((option) => (
value={option.value} <ToggleGroupItem
aria-label={option.label} key={option.value}
> value={option.value}
{option.label} aria-label={option.label}
</ToggleGroupItem> >
))} {option.label}
</ToggleGroup> </ToggleGroupItem>
</div> ))}
</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>
</div> </div>
@@ -625,6 +619,9 @@ export function AudioConverterPage() {
</p> </p>
)} )}
</div> </div>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
<span className="text-xs uppercase text-muted-foreground"> <span className="text-xs uppercase text-muted-foreground">
{file.format} {file.format}
</span> </span>
+3 -2
View File
@@ -9,17 +9,18 @@ interface DownloadProgressProps {
} }
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) { export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return ( return (
<div className="w-full space-y-2 mt-4"> <div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2"> <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"> <Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" /> <StopCircle className="h-4 w-4" />
Stop Stop
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{progress}% -{" "} {clampedProgress}% -{" "}
{currentTrack {currentTrack
? `${currentTrack.name} - ${currentTrack.artists}` ? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."} : "Preparing download..."}
+1 -1
View File
@@ -120,7 +120,7 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden"> <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"> <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> <DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ( {(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>
);
}
+5 -3
View File
@@ -56,8 +56,8 @@ interface PlaylistInfoProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: 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) => 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) => 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; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void; onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void; onDownloadAllCovers?: () => void;
@@ -137,7 +137,9 @@ export function PlaylistInfo({
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="font-medium">{playlistInfo.owner.display_name}</span> <span className="font-medium">{playlistInfo.owner.display_name}</span>
<span></span> <span></span>
<span>{playlistInfo.tracks.total} songs</span> <span>
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
</span>
<span></span> <span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span> <span>{playlistInfo.followers.total.toLocaleString()} followers</span>
</div> </div>
+489 -32
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { Search, Info, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
Tooltip, Tooltip,
@@ -10,16 +10,28 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory"; import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } 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 { interface SearchBarProps {
url: string; url: string;
loading: boolean; loading: boolean;
onUrlChange: (url: string) => void; onUrlChange: (url: string) => void;
onFetch: () => void; onFetch: () => void;
onFetchUrl: (url: string) => Promise<void>;
history: HistoryItem[]; history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void; onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void; onHistoryRemove: (id: string) => void;
hasResult: boolean; hasResult: boolean;
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
} }
export function SearchBar({ export function SearchBar({
@@ -27,68 +39,513 @@ export function SearchBar({
loading, loading,
onUrlChange, onUrlChange,
onFetch, onFetch,
onFetchUrl,
history, history,
onHistorySelect, onHistorySelect,
onHistoryRemove, onHistoryRemove,
hasResult, hasResult,
searchMode,
onSearchModeChange,
}: SearchBarProps) { }: 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 ( return (
<div className="space-y-3"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label> {/* 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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" /> <Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p> {!searchMode ? (
<p className="mt-1">Note: Playlist must be public (not private)</p> <>
<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> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<InputWithContext {!searchMode ? (
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<> <>
<Spinner /> <InputWithContext
Fetching... id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</> </>
) : ( ) : (
<> <>
<Search className="h-4 w-4" /> <InputWithContext
Fetch 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>
)}
</> </>
)} )}
</Button> </div>
{!searchMode && (
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<CloudDownload className="h-4 w-4" />
Fetch
</>
)}
</Button>
)}
</div> </div>
</div> </div>
{!hasResult && (
{!searchMode && !hasResult && (
<FetchHistory <FetchHistory
history={history} history={history}
onSelect={onHistorySelect} onSelect={onHistorySelect}
onRemove={onHistoryRemove} 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> </div>
); );
} }
+91 -59
View File
@@ -12,6 +12,14 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; 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 { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
@@ -44,6 +52,7 @@ export function SettingsPage() {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings()); const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings); const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false);
useEffect(() => { useEffect(() => {
applyThemeMode(savedSettings.themeMode); applyThemeMode(savedSettings.themeMode);
@@ -76,6 +85,8 @@ export function SettingsPage() {
const settingsWithDefaults = await getSettingsWithDefaults(); const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults); setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults); setTempSettings(settingsWithDefaults);
// Save to localStorage so it persists on reload
saveSettings(settingsWithDefaults);
} }
}; };
loadDefaults(); loadDefaults();
@@ -94,6 +105,7 @@ export function SettingsPage() {
applyThemeMode(defaultSettings.themeMode); applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme); applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily); applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default"); toast.success("Settings reset to default");
}; };
@@ -305,37 +317,39 @@ export function SettingsPage() {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Select <div className="flex gap-2">
value={tempSettings.folderPreset} <Select
onValueChange={(value: FolderPreset) => { value={tempSettings.folderPreset}
const preset = FOLDER_PRESETS[value]; onValueChange={(value: FolderPreset) => {
setTempSettings(prev => ({ const preset = FOLDER_PRESETS[value];
...prev, setTempSettings(prev => ({
folderPreset: value, ...prev,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template folderPreset: value,
})); folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
}} }));
> }}
<SelectTrigger className="h-9"> >
<SelectValue /> <SelectTrigger className="h-9 w-fit">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => ( <SelectContent>
<SelectItem key={key} value={key}>{label}</SelectItem> {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
))} <SelectItem key={key} value={key}>{label}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
{tempSettings.folderPreset === "custom" && ( </Select>
<InputWithContext {tempSettings.folderPreset === "custom" && (
value={tempSettings.folderTemplate} <InputWithContext
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} value={tempSettings.folderTemplate}
placeholder="{artist}/{album}" onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
className="h-9 text-sm" placeholder="{artist}/{album}"
/> className="h-9 text-sm flex-1"
)} />
)}
</div>
{tempSettings.folderTemplate && ( {tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span> 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> </p>
)} )}
</div> </div>
@@ -355,37 +369,39 @@ export function SettingsPage() {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Select <div className="flex gap-2">
value={tempSettings.filenamePreset} <Select
onValueChange={(value: FilenamePreset) => { value={tempSettings.filenamePreset}
const preset = FILENAME_PRESETS[value]; onValueChange={(value: FilenamePreset) => {
setTempSettings(prev => ({ const preset = FILENAME_PRESETS[value];
...prev, setTempSettings(prev => ({
filenamePreset: value, ...prev,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template filenamePreset: value,
})); filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
}} }));
> }}
<SelectTrigger className="h-9"> >
<SelectValue /> <SelectTrigger className="h-9 w-fit">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => ( <SelectContent>
<SelectItem key={key} value={key}>{label}</SelectItem> {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
))} <SelectItem key={key} value={key}>{label}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
{tempSettings.filenamePreset === "custom" && ( </Select>
<InputWithContext {tempSettings.filenamePreset === "custom" && (
value={tempSettings.filenameTemplate} <InputWithContext
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} value={tempSettings.filenameTemplate}
placeholder="{track}. {title}" onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
className="h-9 text-sm" placeholder="{track}. {title}"
/> className="h-9 text-sm flex-1"
)} />
)}
</div>
{tempSettings.filenameTemplate && ( {tempSettings.filenameTemplate && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span> 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> </p>
)} )}
</div> </div>
@@ -394,7 +410,7 @@ export function SettingsPage() {
{/* Actions */} {/* Actions */}
<div className="flex gap-2 justify-between pt-4 border-t"> <div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={handleReset} className="gap-1.5"> <Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
Reset to Default Reset to Default
</Button> </Button>
@@ -403,6 +419,22 @@ export function SettingsPage() {
Save Changes Save Changes
</Button> </Button>
</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 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> </div>
); );
} }
+143 -49
View File
@@ -1,4 +1,11 @@
import { Home, Settings, Bug, Activity, FileMusic, LayoutGrid } from "lucide-react"; 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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -7,7 +14,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
interface SidebarProps { interface SidebarProps {
currentPage: PageType; currentPage: PageType;
@@ -15,57 +22,129 @@ interface SidebarProps {
} }
export function Sidebar({ currentPage, onPageChange }: SidebarProps) { export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const navItems = [
{ id: "main" as PageType, icon: Home, label: "Home" },
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
{ id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" },
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
];
return ( 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="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"> <div className="flex flex-col gap-2 flex-1">
{navItems.map((item) => ( {/* Home */}
<Tooltip key={item.id} delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant={currentPage === item.id ? "secondary" : "ghost"} variant={currentPage === "main" ? "secondary" : "ghost"}
size="icon" size="icon"
className="h-10 w-10" className="h-10 w-10"
onClick={() => onPageChange(item.id)} onClick={() => onPageChange("main")}
> >
<item.icon className="h-5 w-5" /> <HomeIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>{item.label}</p> <p>Home</p>
</TooltipContent> </TooltipContent>
</Tooltip> </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> </div>
{/* GitHub - below debug */} {/* Bottom icons */}
<Tooltip delayDuration={0}> <div className="mt-auto flex flex-col gap-2">
<TooltipTrigger asChild> <Tooltip delayDuration={0}>
<Button <TooltipTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
className="h-10 w-10" size="icon"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")} className="h-10 w-10"
> onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor"> >
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> <GithubIcon size={20} />
</svg> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent side="right">
<TooltipContent side="right"> <p>Report Bug</p>
<p>Report Bug</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip>
{/* Other Projects at bottom */}
<div className="mt-auto">
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -74,13 +153,28 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
className="h-10 w-10" className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")} onClick={() => openExternal("https://exyezed.cc/")}
> >
<LayoutGrid className="h-5 w-5" /> <BlocksIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Other Projects</p> <p>Other Projects</p>
</TooltipContent> </TooltipContent>
</Tooltip> </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>
</div> </div>
); );
+41 -38
View File
@@ -1,4 +1,3 @@
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
@@ -25,10 +24,13 @@ interface TrackInfoProps {
checkingAvailability?: boolean; checkingAvailability?: boolean;
availability?: TrackAvailability; availability?: TrackAvailability;
downloadingCover?: boolean; 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; onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: 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; onOpenFolder: () => void;
} }
@@ -46,51 +48,26 @@ export function TrackInfo({
checkingAvailability, checkingAvailability,
availability, availability,
downloadingCover, downloadingCover,
downloadedCover,
failedCover,
skippedCover,
onDownload, onDownload,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability, onCheckAvailability,
onDownloadCover, onDownloadCover,
onOpenFolder, onOpenFolder,
}: TrackInfoProps) { }: TrackInfoProps) {
const [isHoveringCover, setIsHoveringCover] = useState(false);
return ( return (
<Card> <Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
<div <div className="shrink-0">
className="shrink-0 relative"
onMouseEnter={() => setIsHoveringCover(true)}
onMouseLeave={() => setIsHoveringCover(false)}
>
{track.images && ( {track.images && (
<> <img
<img src={track.images}
src={track.images} alt={track.name}
alt={track.name} className="w-48 h-48 rounded-md shadow-lg object-cover"
className="w-48 h-48 rounded-md shadow-lg object-cover" />
/>
{isHoveringCover && onDownloadCover && (
<div className="absolute inset-0 bg-black/50 rounded-md flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="secondary"
className="cursor-pointer"
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name)}
disabled={downloadingCover}
>
{downloadingCover ? <Spinner /> : <ImageDown className="h-5 w-5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
</div>
)}
</>
)} )}
</div> </div>
<div className="flex-1 space-y-4 min-w-0"> <div className="flex-1 space-y-4 min-w-0">
@@ -136,7 +113,7 @@ export function TrackInfo({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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" variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id} disabled={downloadingLyricsTrack === track.spotify_id}
> >
@@ -158,6 +135,32 @@ export function TrackInfo({
</TooltipContent> </TooltipContent>
</Tooltip> </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 && ( {track.spotify_id && onCheckAvailability && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+4 -4
View File
@@ -50,9 +50,9 @@ interface TrackListProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: 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) => 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; onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: 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; onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void; onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
@@ -339,7 +339,7 @@ export function TrackList({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
onClick={() => onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1) 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" size="sm"
variant="outline" variant="outline"
@@ -369,7 +369,7 @@ export function TrackList({
<Button <Button
onClick={() => { onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`; 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); 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" size="sm"
variant="outline" variant="outline"
+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 };
-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 };
+18 -3
View File
@@ -23,7 +23,10 @@ export function useCover() {
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
position?: number, position?: number,
trackId?: string trackId?: string,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => { ) => {
if (!coverUrl) { if (!coverUrl) {
toast.error("No cover URL found for this track"); toast.error("No cover URL found for this track");
@@ -72,10 +75,14 @@ export function useCover() {
cover_url: coverUrl, cover_url: coverUrl,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: artistName,
album_name: albumName || "",
album_artist: albumArtist || "",
release_date: releaseDate || "",
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: position || 0, position: position || 0,
disc_number: discNumber || 0,
}); });
if (response.success) { if (response.success) {
@@ -145,12 +152,16 @@ export function useCover() {
// Replace forward slashes in template data values to prevent them from being interpreted as path separators // Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__"; 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 // Build output path using template system
const templateData: TemplateData = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder),
track: i + 1, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
@@ -176,10 +187,14 @@ export function useCover() {
cover_url: track.images, cover_url: track.images,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: i + 1, position: trackPosition,
disc_number: track.disc_number,
}); });
if (response.success) { if (response.success) {
+152 -38
View File
@@ -6,6 +6,27 @@ import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; 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() { export function useDownload() {
const [downloadProgress, setDownloadProgress] = useState<number>(0); const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
@@ -53,6 +74,7 @@ export function useDownload() {
const templateData: TemplateData = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: position, track: position,
year: releaseYear, year: releaseYear,
@@ -296,12 +318,12 @@ export function useDownload() {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false; let useAlbumTrackNumber = false;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators // Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = { const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder), artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder),
track: position, track: position,
year: releaseYear, year: releaseYear,
@@ -597,7 +619,40 @@ export function useDownload() {
setBulkDownloadType("selected"); setBulkDownloadType("selected");
setDownloadProgress(0); 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 { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = []; const itemIDs: string[] = [];
for (const isrc of selectedTracks) { for (const isrc of selectedTracks) {
@@ -609,65 +664,78 @@ export function useDownload() {
track?.album_name || "" track?.album_name || ""
); );
itemIDs.push(itemID); 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 successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0; let skippedCount = existingISRCs.size;
const total = selectedTracks.length; 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) { if (shouldStopDownloadRef.current) {
toast.info( toast.info(
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.` `Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
); );
break; break;
} }
const isrc = selectedTracks[i]; const track = tracksToDownload[i];
const track = allTracks.find((t) => t.isrc === isrc); const isrc = track.isrc;
const itemID = itemIDs[i]; // Find original index and itemID
const originalIndex = selectedTracks.indexOf(isrc);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(isrc); setDownloadingTrack(isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
if (track) {
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
}
try { try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY) // Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track?.release_date?.substring(0, 4); const releaseYear = track.release_date?.substring(0, 4);
// Download with pre-created itemID // Download with pre-created itemID
const response = await downloadWithItemID( const response = await downloadWithItemID(
isrc, isrc,
settings, settings,
itemID, itemID,
track?.name, track.name,
track?.artists, track.artists,
track?.album_name, track.album_name,
folderName, folderName,
i + 1, // Sequential position based on selection order originalIndex + 1, // Sequential position based on selection order
track?.spotify_id, track.spotify_id,
track?.duration_ms, track.duration_ms,
isAlbum, isAlbum,
releaseYear, releaseYear,
track?.album_artist || "", // Use album_artist from Spotify metadata track.album_artist || "", // Use album_artist from Spotify metadata
track?.release_date, track.release_date,
track?.images, // Spotify cover URL track.images, // Spotify cover URL
track?.track_number, // Spotify album track number track.track_number, // Spotify album track number
track?.disc_number, // Spotify disc number track.disc_number, // Spotify disc number
track?.total_tracks // Total tracks in album track.total_tracks // Total tracks in album
); );
if (response.success) { if (response.success) {
if (response.already_exists) { if (response.already_exists) {
skippedCount++; 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)); setSkippedTracks((prev) => new Set(prev).add(isrc));
} else { } else {
successCount++; successCount++;
logger.success(`downloaded: ${track?.name} - ${track?.artists}`); logger.success(`downloaded: ${track.name} - ${track.artists}`);
} }
setDownloadedTracks((prev) => new Set(prev).add(isrc)); setDownloadedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => { setFailedTracks((prev) => {
@@ -677,19 +745,20 @@ export function useDownload() {
}); });
} else { } else {
errorCount++; errorCount++;
logger.error(`failed: ${track?.name} - ${track?.artists}`); logger.error(`failed: ${track.name} - ${track.artists}`);
setFailedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => new Set(prev).add(isrc));
} }
} catch (err) { } catch (err) {
errorCount++; errorCount++;
logger.error(`error: ${track?.name} - ${err}`); logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => new Set(prev).add(isrc));
// Mark item as failed in queue // Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); 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); setDownloadingTrack(null);
@@ -740,7 +809,35 @@ export function useDownload() {
setBulkDownloadType("all"); setBulkDownloadType("all");
setDownloadProgress(0); 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 { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = []; const itemIDs: string[] = [];
for (const track of tracksWithIsrc) { for (const track of tracksWithIsrc) {
@@ -751,23 +848,39 @@ export function useDownload() {
track.album_name || "" track.album_name || ""
); );
itemIDs.push(itemID); 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 successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0; let skippedCount = existingISRCs.size;
const total = tracksWithIsrc.length; 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) { if (shouldStopDownloadRef.current) {
toast.info( toast.info(
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.` `Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
); );
break; break;
} }
const track = tracksWithIsrc[i]; const track = tracksToDownload[i];
const itemID = itemIDs[i]; // Find original index and itemID
const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(track.isrc); setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists }); setCurrentDownloadInfo({ name: track.name, artists: track.artists });
@@ -784,7 +897,7 @@ export function useDownload() {
track.artists, track.artists,
track.album_name, track.album_name,
folderName, folderName,
i + 1, originalIndex + 1,
track.spotify_id, track.spotify_id,
track.duration_ms, track.duration_ms,
isAlbum, isAlbum,
@@ -826,7 +939,8 @@ export function useDownload() {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); 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); setDownloadingTrack(null);
+22 -6
View File
@@ -21,7 +21,10 @@ export function useLyrics() {
artistName: string, artistName: string,
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
position?: number position?: number,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => { ) => {
if (!spotifyId) { if (!spotifyId) {
toast.error("No Spotify ID found for this track"); toast.error("No Spotify ID found for this track");
@@ -71,11 +74,15 @@ export function useLyrics() {
spotify_id: spotifyId, spotify_id: spotifyId,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: position || 0, position: position || 0,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
disc_number: discNumber,
}); });
if (response.success) { if (response.success) {
@@ -126,7 +133,8 @@ export function useLyrics() {
let skipped = 0; let skipped = 0;
const total = tracksWithSpotifyId.length; const total = tracksWithSpotifyId.length;
for (const track of tracksWithSpotifyId) { for (let i = 0; i < tracksWithSpotifyId.length; i++) {
const track = tracksWithSpotifyId[i];
if (stopBulkDownloadRef.current) { if (stopBulkDownloadRef.current) {
toast.info("Lyrics download stopped by user"); toast.info("Lyrics download stopped by user");
break; break;
@@ -142,12 +150,18 @@ export function useLyrics() {
// Replace forward slashes in template data values to prevent them from being interpreted as path separators // Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__"; 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 // Build output path using template system
const templateData: TemplateData = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder),
track: track.track_number, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
@@ -169,17 +183,19 @@ export function useLyrics() {
} }
} }
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({ const response = await downloadLyrics({
spotify_id: id, spotify_id: id,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: track.track_number || 0, position: trackPosition,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
disc_number: track.disc_number,
}); });
if (response.success) { if (response.success) {
+1 -1
View File
@@ -77,7 +77,7 @@
} }
body { body {
@apply bg-background text-foreground; @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 { code, pre, .font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
+29 -10
View File
@@ -3,10 +3,10 @@ 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"; 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 // Folder structure presets
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom"; 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 // Filename format presets
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom"; 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 { export interface Settings {
downloadPath: string; downloadPath: string;
@@ -38,9 +38,18 @@ export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: str
"none": { label: "No Subfolder", template: "" }, "none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" }, "artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" }, "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-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" }, "artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"custom": { label: "Custom...", template: "" }, "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 // Filename preset templates
@@ -51,18 +60,24 @@ export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template:
"track-title": { label: "Track. Title", template: "{track}. {title}" }, "track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" }, "track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" }, "track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"custom": { label: "Custom...", template: "" }, "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 // Available template variables
export const TEMPLATE_VARIABLES = [ export const TEMPLATE_VARIABLES = [
{ key: "{artist}", description: "Artist name", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{title}", description: "Track title", example: "Shake It Off" }, { key: "{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: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" }, { key: "{year}", description: "Release year", example: "2014" },
{ key: "{isrc}", description: "ISRC code", example: "USCJY1431309" },
{ key: "{playlist}", description: "Playlist name", example: "My Playlist" },
]; ];
// Auto-detect operating system // Auto-detect operating system
@@ -97,7 +112,7 @@ export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: strin
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' }, { value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", 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: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' }, { value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", 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: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' }, { value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
@@ -194,8 +209,10 @@ export function getSettings(): Settings {
export interface TemplateData { export interface TemplateData {
artist?: string; artist?: string;
album?: string; album?: string;
album_artist?: string;
title?: string; title?: string;
track?: number; track?: number;
disc?: number;
year?: string; year?: string;
isrc?: string; isrc?: string;
playlist?: string; playlist?: string;
@@ -207,10 +224,12 @@ export function parseTemplate(template: string, data: TemplateData): string {
let result = template; let result = template;
// Replace each variable // Replace each variable
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist"); result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album"); result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{title\}/g, data.title || "Unknown Title"); 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(/\{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(/\{year\}/g, data.year || "0000");
result = result.replace(/\{isrc\}/g, data.isrc || ""); result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || ""); result = result.replace(/\{playlist\}/g, data.playlist || "");
+19
View File
@@ -168,6 +168,7 @@ export interface SpectrumData {
export interface AnalysisResult { export interface AnalysisResult {
file_path: string; file_path: string;
file_size: number;
sample_rate: number; sample_rate: number;
channels: number; channels: number;
bits_per_sample: number; bits_per_sample: number;
@@ -184,11 +185,15 @@ export interface LyricsDownloadRequest {
spotify_id: string; spotify_id: string;
track_name: string; track_name: string;
artist_name: string; artist_name: string;
album_name?: string;
album_artist?: string;
release_date?: string;
output_dir?: string; output_dir?: string;
filename_format?: string; filename_format?: string;
track_number?: boolean; track_number?: boolean;
position?: number; position?: number;
use_album_track_number?: boolean; use_album_track_number?: boolean;
disc_number?: number;
} }
export interface LyricsDownloadResponse { export interface LyricsDownloadResponse {
@@ -213,10 +218,14 @@ export interface CoverDownloadRequest {
cover_url: string; cover_url: string;
track_name: string; track_name: string;
artist_name: string; artist_name: string;
album_name?: string;
album_artist?: string;
release_date?: string;
output_dir?: string; output_dir?: string;
filename_format?: string; filename_format?: string;
track_number?: boolean; track_number?: boolean;
position?: number; position?: number;
disc_number?: number;
} }
export interface CoverDownloadResponse { export interface CoverDownloadResponse {
@@ -228,3 +237,13 @@ export interface CoverDownloadResponse {
} }
export interface AudioMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
+1 -1
View File
@@ -1,6 +1,6 @@
module spotiflac module spotiflac
go 1.25.4 go 1.25.5
require ( require (
github.com/bogem/id3v2/v2 v2.1.4 github.com/bogem/id3v2/v2 v2.1.4
-12
View File
@@ -1,12 +0,0 @@
[
"vogel.qqdl.site",
"maus.qqdl.site",
"hund.qqdl.site",
"katze.qqdl.site",
"wolf.qqdl.site",
"tidal.kinoplus.online",
"tidal-api.binimum.org",
"tidal-api-2.binimum.org",
"dev-api.squid.wtf",
"triton.squid.wtf"
]
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "6.8" "productVersion": "7.0"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",