diff --git a/app.go b/app.go
index 4c9e00e..64a65e3 100644
--- a/app.go
+++ b/app.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "encoding/base64"
"encoding/json"
"fmt"
"os"
@@ -124,6 +125,56 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
return string(jsonData), nil
}
+// SpotifySearchRequest represents the request structure for searching Spotify
+type SpotifySearchRequest struct {
+ Query string `json:"query"`
+ Limit int `json:"limit"`
+}
+
+// SearchSpotify searches for tracks, albums, artists, and playlists on Spotify
+func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, error) {
+ if req.Query == "" {
+ return nil, fmt.Errorf("search query is required")
+ }
+
+ if req.Limit <= 0 {
+ req.Limit = 10
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ return backend.SearchSpotify(ctx, req.Query, req.Limit)
+}
+
+// SpotifySearchByTypeRequest represents the request for searching by specific type with offset
+type SpotifySearchByTypeRequest struct {
+ Query string `json:"query"`
+ SearchType string `json:"search_type"` // track, album, artist, playlist
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+// SearchSpotifyByType searches for a specific type with offset support for pagination
+func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.SearchResult, error) {
+ if req.Query == "" {
+ return nil, fmt.Errorf("search query is required")
+ }
+
+ if req.SearchType == "" {
+ return nil, fmt.Errorf("search type is required")
+ }
+
+ if req.Limit <= 0 {
+ req.Limit = 50
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ return backend.SearchSpotifyByType(ctx, req.Query, req.SearchType, req.Limit, req.Offset)
+}
+
// DownloadTrack downloads a track by ISRC
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.ISRC == "" {
@@ -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
if req.TrackName != "" && req.ArtistName != "" {
- expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
+ expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
@@ -502,11 +553,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
+ AlbumName string `json:"album_name"`
+ AlbumArtist string `json:"album_artist"`
+ ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
+ DiscNumber int `json:"disc_number"`
}
// DownloadLyrics downloads lyrics for a single track
@@ -523,11 +578,15 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
SpotifyID: req.SpotifyID,
TrackName: req.TrackName,
ArtistName: req.ArtistName,
+ AlbumName: req.AlbumName,
+ AlbumArtist: req.AlbumArtist,
+ ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
UseAlbumTrackNumber: req.UseAlbumTrackNumber,
+ DiscNumber: req.DiscNumber,
}
resp, err := client.DownloadLyrics(backendReq)
@@ -546,10 +605,14 @@ type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
+ AlbumName string `json:"album_name"`
+ AlbumArtist string `json:"album_artist"`
+ ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
+ DiscNumber int `json:"disc_number"`
}
// DownloadCover downloads cover art for a single track
@@ -566,10 +629,14 @@ func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResp
CoverURL: req.CoverURL,
TrackName: req.TrackName,
ArtistName: req.ArtistName,
+ AlbumName: req.AlbumName,
+ AlbumArtist: req.AlbumArtist,
+ ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
+ DiscNumber: req.DiscNumber,
}
resp, err := client.DownloadCover(backendReq)
@@ -713,6 +780,49 @@ func (a *App) RenameFilesByMetadata(files []string, format string) []backend.Ren
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"`
diff --git a/backend/amazon.go b/backend/amazon.go
index 7b13c3a..4c4e2d4 100644
--- a/backend/amazon.go
+++ b/backend/amazon.go
@@ -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)
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)
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 != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
+ safeAlbum := sanitizeFilename(spotifyAlbumName)
+ safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
+
+ // Extract year from release date
+ year := ""
+ if len(spotifyReleaseDate) >= 4 {
+ year = spotifyReleaseDate[:4]
+ }
// Build filename based on format settings
var newFilename string
@@ -412,6 +420,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
+ newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
+ newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
+ newFilename = strings.ReplaceAll(newFilename, "{year}", year)
+
+ // Handle disc number
+ if spotifyDiscNumber > 0 {
+ newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
+ } else {
+ newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
+ }
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
diff --git a/backend/cover.go b/backend/cover.go
index e795dca..269b874 100644
--- a/backend/cover.go
+++ b/backend/cover.go
@@ -22,10 +22,14 @@ type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
+ AlbumName string `json:"album_name"`
+ AlbumArtist string `json:"album_artist"`
+ ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
+ DiscNumber int `json:"disc_number"`
}
// CoverDownloadResponse represents the response from cover download
@@ -50,9 +54,17 @@ func NewCoverClient() *CoverClient {
}
// 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)
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
@@ -61,6 +73,16 @@ func buildCoverFilename(trackName, artistName, filenameFormat string, includeTra
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
+ filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
+ filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
+ filename = strings.ReplaceAll(filename, "{year}", year)
+
+ // Handle disc number
+ if discNumber > 0 {
+ filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+ } else {
+ filename = strings.ReplaceAll(filename, "{disc}", "")
+ }
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
@@ -176,7 +198,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
if filenameFormat == "" {
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)
// Check if file already exists
diff --git a/backend/filemanager.go b/backend/filemanager.go
index 8955a05..43e56df 100644
--- a/backend/filemanager.go
+++ b/backend/filemanager.go
@@ -341,12 +341,18 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
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(metadata.Year))
+ result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
// Track number with padding
if metadata.TrackNumber > 0 {
diff --git a/backend/filename.go b/backend/filename.go
index f03bb86..19de449 100644
--- a/backend/filename.go
+++ b/backend/filename.go
@@ -10,10 +10,18 @@ import (
)
// BuildExpectedFilename builds the expected filename based on track metadata and settings
-func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
+func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
+ safeAlbum := sanitizeFilename(albumName)
+ safeAlbumArtist := sanitizeFilename(albumArtist)
+
+ // Extract year from release date (format: YYYY-MM-DD or YYYY)
+ year := ""
+ if len(releaseDate) >= 4 {
+ year = releaseDate[:4]
+ }
var filename string
@@ -22,6 +30,16 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
+ filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
+ filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
+ filename = strings.ReplaceAll(filename, "{year}", year)
+
+ // Handle disc number
+ if discNumber > 0 {
+ filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+ } else {
+ filename = strings.ReplaceAll(filename, "{disc}", "")
+ }
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
diff --git a/backend/lyrics.go b/backend/lyrics.go
index a5b7412..5a7e95a 100644
--- a/backend/lyrics.go
+++ b/backend/lyrics.go
@@ -46,11 +46,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
+ AlbumName string `json:"album_name"`
+ AlbumArtist string `json:"album_artist"`
+ ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
+ DiscNumber int `json:"disc_number"`
}
// LyricsDownloadResponse represents the response from lyrics download
@@ -308,9 +312,17 @@ func msToLRCTimestamp(msStr string) string {
}
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
-func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
+func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
+ safeAlbum := sanitizeFilename(albumName)
+ safeAlbumArtist := sanitizeFilename(albumArtist)
+
+ // Extract year from release date (format: YYYY-MM-DD or YYYY)
+ year := ""
+ if len(releaseDate) >= 4 {
+ year = releaseDate[:4]
+ }
var filename string
@@ -319,6 +331,16 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
+ filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
+ filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
+ filename = strings.ReplaceAll(filename, "{year}", year)
+
+ // Handle disc number
+ if discNumber > 0 {
+ filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+ } else {
+ filename = strings.ReplaceAll(filename, "{disc}", "")
+ }
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
@@ -378,7 +400,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
if filenameFormat == "" {
filenameFormat = "title-artist" // default
}
- filename := buildLyricsFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
+ filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
diff --git a/backend/qobuz.go b/backend/qobuz.go
index 905aec3..0729db0 100644
--- a/backend/qobuz.go
+++ b/backend/qobuz.go
@@ -263,7 +263,7 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err
}
-func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
+func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Determine track number to use
@@ -272,11 +272,27 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
numberToUse = trackNumber
}
+ // Extract year from release date (format: YYYY-MM-DD or YYYY)
+ year := ""
+ if len(releaseDate) >= 4 {
+ year = releaseDate[:4]
+ }
+
// Check if format is a template (contains {})
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist)
+ filename = strings.ReplaceAll(filename, "{album}", album)
+ filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
+ filename = strings.ReplaceAll(filename, "{year}", year)
+
+ // Handle disc number
+ if discNumber > 0 {
+ filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+ } else {
+ filename = strings.ReplaceAll(filename, "{disc}", "")
+ }
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
@@ -355,6 +371,8 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
+ safeAlbum := sanitizeFilename(albumTitle)
+ safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC)
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)
- 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)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go
index 2027e04..10cad9d 100644
--- a/backend/spotify_metadata.go
+++ b/backend/spotify_metadata.go
@@ -2,11 +2,7 @@ package backend
import (
"context"
- "crypto/hmac"
- "crypto/sha1"
- "encoding/base32"
- "encoding/binary"
- "encoding/hex"
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -14,8 +10,6 @@ import (
"math/rand"
"net/http"
"net/url"
- "os"
- "path/filepath"
"strconv"
"strings"
"sync"
@@ -23,13 +17,12 @@ import (
)
const (
- spotifyTokenURL = "https://open.spotify.com/api/token"
- playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
- albumBaseURL = "https://api.spotify.com/v1/albums/%s"
- trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
- artistBaseURL = "https://api.spotify.com/v1/artists/%s"
- artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
- secretBytesRemotePath = "https://cdn.jsdelivr.net/gh/afkarxyz/secretBytes@refs/heads/main/secrets/secretBytes.json"
+ spotifyTokenURL = "https://accounts.spotify.com/api/token"
+ playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
+ albumBaseURL = "https://api.spotify.com/v1/albums/%s"
+ trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
+ artistBaseURL = "https://api.spotify.com/v1/artists/%s"
+ artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
)
var (
@@ -38,18 +31,37 @@ var (
// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API.
type SpotifyMetadataClient struct {
- httpClient *http.Client
- rng *rand.Rand
- rngMu sync.Mutex
- userAgent string
+ httpClient *http.Client
+ clientID string
+ clientSecret string
+ cachedToken string
+ tokenExpiresAt time.Time
+ rng *rand.Rand
+ rngMu sync.Mutex
+ userAgent string
}
-// NewSpotifyMetadataClient creates a ready-to-use client with sane defaults.
+// NewSpotifyMetadataClient creates a ready-to-use client with Official Spotify API credentials.
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
+
+ // Decode client ID from base64
+ clientID := ""
+ if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
+ clientID = string(decoded)
+ }
+
+ // Decode client secret from base64
+ clientSecret := ""
+ if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
+ clientSecret = string(decoded)
+ }
+
c := &SpotifyMetadataClient{
- httpClient: &http.Client{Timeout: 15 * time.Second},
- rng: rand.New(src),
+ httpClient: &http.Client{Timeout: 15 * time.Second},
+ clientID: clientID,
+ clientSecret: clientSecret,
+ rng: rand.New(src),
}
c.userAgent = c.randomUserAgent()
return c
@@ -187,17 +199,10 @@ type spotifyURI struct {
DiscographyGroup string
}
-type secretEntry struct {
- Version int `json:"version"`
- Secret []int `json:"secret"`
-}
-
-type serverTimeResponse struct {
- ServerTime int64 `json:"serverTime"`
-}
-
type accessTokenResponse struct {
- AccessToken string `json:"accessToken"`
+ AccessToken string `json:"access_token"`
+ ExpiresIn interface{} `json:"expires_in"` // Can be number or string
+ TokenType string `json:"token_type"`
}
type image struct {
@@ -352,7 +357,9 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
case "artist_discography":
return c.fetchArtistDiscography(ctx, parsed, token, batch, delay)
case "artist":
- return c.fetchArtist(ctx, parsed.ID, token)
+ // Automatically fetch discography for artist URLs to get full data (albums + tracks)
+ discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
+ return c.fetchArtistDiscography(ctx, discographyParsed, token, batch, delay)
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
}
@@ -859,211 +866,58 @@ func (c *SpotifyMetadataClient) randRange(min, max int) int {
}
func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) {
- code, serverTime, version, err := c.generateTOTP(ctx)
+ // Return cached token if still valid
+ if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) {
+ return c.cachedToken, nil
+ }
+
+ // Prepare request body for Client Credentials Flow
+ data := url.Values{}
+ data.Set("grant_type", "client_credentials")
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
- timestampMS := time.Now().UnixMilli()
- params := url.Values{}
- params.Set("reason", "init")
- params.Set("productType", "web-player")
- params.Set("totp", code)
- params.Set("totpServerTime", strconv.FormatInt(serverTime, 10))
- params.Set("totpVer", strconv.Itoa(version))
- params.Set("sTime", strconv.FormatInt(serverTime, 10))
- params.Set("cTime", strconv.FormatInt(timestampMS, 10))
- params.Set("buildVer", "web-player_2025-07-02_1720000000000_12345678")
- params.Set("buildDate", "2025-07-02")
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotifyTokenURL, nil)
- if err != nil {
- return "", err
- }
- req.URL.RawQuery = params.Encode()
- req.Header = c.baseHeaders()
+ // Set Basic Auth header
+ req.SetBasicAuth(c.clientID, c.clientSecret)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
+ defer resp.Body.Close()
+
body, err := io.ReadAll(resp.Body)
- resp.Body.Close()
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("failed to get access token. Status code: %d", resp.StatusCode)
+ return "", fmt.Errorf("failed to get access token. Status code: %d, Response: %s", resp.StatusCode, string(body))
}
var token accessTokenResponse
if err := json.Unmarshal(body, &token); err != nil {
return "", err
}
+
if token.AccessToken == "" {
return "", errors.New("failed to get access token: empty token received")
}
+
+ // Cache the token
+ c.cachedToken = token.AccessToken
+ // Official API returns expires_in in seconds
+ if expiresIn, ok := token.ExpiresIn.(float64); ok {
+ c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) // Refresh 60 seconds before expiry
+ }
+
return token.AccessToken, nil
}
-func (c *SpotifyMetadataClient) generateTOTP(ctx context.Context) (string, int64, int, error) {
- secrets, _, err := c.fetchSecretBytes(ctx)
- if err != nil {
- return "", 0, 0, err
- }
- if len(secrets) == 0 {
- return "", 0, 0, errors.New("no secrets available")
- }
-
- latest := secrets[0]
- for _, entry := range secrets[1:] {
- if entry.Version > latest.Version {
- latest = entry
- }
- }
-
- builder := strings.Builder{}
- for idx, val := range latest.Secret {
- processed := val ^ ((idx % 33) + 9)
- builder.WriteString(strconv.Itoa(processed))
- }
-
- utfBytes := []byte(builder.String())
- hexStr := hex.EncodeToString(utfBytes)
- secretBytes, err := hex.DecodeString(hexStr)
- if err != nil {
- return "", 0, 0, err
- }
- b32Secret := base32.StdEncoding.EncodeToString(secretBytes)
-
- serverTime, err := c.fetchServerTime(ctx)
- if err != nil {
- return "", 0, 0, err
- }
-
- code, err := computeTOTP(b32Secret, serverTime)
- if err != nil {
- return "", 0, 0, err
- }
-
- return code, serverTime, latest.Version, nil
-}
-
-func (c *SpotifyMetadataClient) fetchSecretBytes(ctx context.Context) ([]secretEntry, bool, error) {
- // Add cache busting parameter with current timestamp
- urlWithCacheBust := fmt.Sprintf("%s?t=%d", secretBytesRemotePath, time.Now().Unix())
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlWithCacheBust, nil)
- if err == nil {
- // Add headers to bypass cache
- req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
- req.Header.Set("Pragma", "no-cache")
- req.Header.Set("Expires", "0")
-
- resp, err := c.httpClient.Do(req)
- if err == nil {
- body, readErr := io.ReadAll(resp.Body)
- resp.Body.Close()
- if readErr == nil && resp.StatusCode == http.StatusOK {
- var secrets []secretEntry
- if jsonErr := json.Unmarshal(body, &secrets); jsonErr == nil {
- return secrets, false, nil
- }
- }
- }
- }
-
- home, err := os.UserHomeDir()
- if err != nil {
- return nil, false, fmt.Errorf("GitHub fetch failed and could not resolve home directory: %w", err)
- }
- localPath := filepath.Join(home, ".spotify-secret", "secretBytes.json")
- data, err := os.ReadFile(localPath)
- if err != nil {
- return nil, false, fmt.Errorf("failed to fetch secrets from both GitHub and local: %w", err)
- }
-
- var secrets []secretEntry
- if err := json.Unmarshal(data, &secrets); err != nil {
- return nil, false, fmt.Errorf("failed to process local secrets: %w", err)
- }
- return secrets, true, nil
-}
-
-func (c *SpotifyMetadataClient) fetchServerTime(ctx context.Context) (int64, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.spotify.com/api/server-time", nil)
- if err != nil {
- return 0, err
- }
- req.Header = c.serverTimeHeaders()
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return 0, err
- }
- body, err := io.ReadAll(resp.Body)
- resp.Body.Close()
- if err != nil {
- return 0, err
- }
-
- if resp.StatusCode != http.StatusOK {
- return 0, fmt.Errorf("failed to get server time. Status code: %d", resp.StatusCode)
- }
-
- var payload serverTimeResponse
- if err := json.Unmarshal(body, &payload); err != nil {
- return 0, err
- }
- if payload.ServerTime == 0 {
- return 0, errors.New("failed to fetch server time from Spotify")
- }
- return payload.ServerTime, nil
-}
-
-func (c *SpotifyMetadataClient) serverTimeHeaders() http.Header {
- h := http.Header{}
- h.Set("Host", "open.spotify.com")
- h.Set("User-Agent", c.randomUserAgent())
- h.Set("Accept", "*/*")
- return h
-}
-
-func computeTOTP(b32Secret string, timestamp int64) (string, error) {
- normalized := strings.ToUpper(strings.ReplaceAll(b32Secret, " ", ""))
- key, err := base32.StdEncoding.DecodeString(normalized)
- if err != nil {
- return "", err
- }
-
- // Normalise milliseconds if necessary.
- if timestamp > 1_000_000_000_000 {
- timestamp /= 1000
- }
-
- counter := uint64(timestamp / 30)
- var buf [8]byte
- binary.BigEndian.PutUint64(buf[:], counter)
-
- mac := hmac.New(sha1.New, key)
- if _, err := mac.Write(buf[:]); err != nil {
- return "", err
- }
- sum := mac.Sum(nil)
- if len(sum) < 20 {
- return "", errors.New("unexpected hmac length for TOTP")
- }
-
- offset := sum[len(sum)-1] & 0x0f
- binaryCode := (int(sum[offset])&0x7f)<<24 |
- (int(sum[offset+1])&0xff)<<16 |
- (int(sum[offset+2])&0xff)<<8 |
- (int(sum[offset+3]) & 0xff)
- otp := binaryCode % 1_000_000
- return fmt.Sprintf("%06d", otp), nil
-}
-
func parseSpotifyURI(input string) (spotifyURI, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
@@ -1239,3 +1093,298 @@ func maxInt(a, b int) int {
}
return b
}
+
+
+// SearchResult represents a single search result item
+type SearchResult struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"` // track, album, artist, playlist
+ Artists string `json:"artists,omitempty"`
+ AlbumName string `json:"album_name,omitempty"`
+ Images string `json:"images"`
+ ReleaseDate string `json:"release_date,omitempty"`
+ ExternalURL string `json:"external_urls"`
+ Duration int `json:"duration_ms,omitempty"`
+ TotalTracks int `json:"total_tracks,omitempty"`
+ Owner string `json:"owner,omitempty"` // for playlists
+}
+
+// SearchResponse contains search results grouped by type
+type SearchResponse struct {
+ Tracks []SearchResult `json:"tracks"`
+ Albums []SearchResult `json:"albums"`
+ Artists []SearchResult `json:"artists"`
+ Playlists []SearchResult `json:"playlists"`
+}
+
+// Spotify API search response structures
+type searchTracksResponse struct {
+ Tracks struct {
+ Items []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ DurationMS int `json:"duration_ms"`
+ ExternalURL externalURL `json:"external_urls"`
+ Artists []artist `json:"artists"`
+ Album struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Images []image `json:"images"`
+ ReleaseDate string `json:"release_date"`
+ ExternalURL externalURL `json:"external_urls"`
+ } `json:"album"`
+ } `json:"items"`
+ } `json:"tracks"`
+}
+
+type searchAlbumsResponse struct {
+ Albums struct {
+ Items []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ AlbumType string `json:"album_type"`
+ TotalTracks int `json:"total_tracks"`
+ ReleaseDate string `json:"release_date"`
+ Images []image `json:"images"`
+ ExternalURL externalURL `json:"external_urls"`
+ Artists []artist `json:"artists"`
+ } `json:"items"`
+ } `json:"albums"`
+}
+
+type searchArtistsResponse struct {
+ Artists struct {
+ Items []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Images []image `json:"images"`
+ ExternalURL externalURL `json:"external_urls"`
+ Followers struct {
+ Total int `json:"total"`
+ } `json:"followers"`
+ } `json:"items"`
+ } `json:"artists"`
+}
+
+type searchPlaylistsResponse struct {
+ Playlists struct {
+ Items []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Images []image `json:"images"`
+ ExternalURL externalURL `json:"external_urls"`
+ Owner struct {
+ DisplayName string `json:"display_name"`
+ } `json:"owner"`
+ Tracks struct {
+ Total int `json:"total"`
+ } `json:"tracks"`
+ } `json:"items"`
+ } `json:"playlists"`
+}
+
+// Search performs a search on Spotify and returns results for tracks, albums, artists, and playlists
+func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) {
+ if query == "" {
+ return nil, errors.New("search query cannot be empty")
+ }
+
+ if limit <= 0 || limit > 50 {
+ limit = 50
+ }
+
+ token, err := c.getAccessToken(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get access token: %w", err)
+ }
+
+ // URL encode the query
+ encodedQuery := url.QueryEscape(query)
+ searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=track,album,artist,playlist&limit=%d", encodedQuery, limit)
+
+ response := &SearchResponse{
+ Tracks: make([]SearchResult, 0),
+ Albums: make([]SearchResult, 0),
+ Artists: make([]SearchResult, 0),
+ Playlists: make([]SearchResult, 0),
+ }
+
+ // Fetch tracks
+ var tracksResp searchTracksResponse
+ if err := c.getJSON(ctx, searchURL, token, &tracksResp); err != nil {
+ return nil, fmt.Errorf("search failed: %w", err)
+ }
+
+ for _, item := range tracksResp.Tracks.Items {
+ response.Tracks = append(response.Tracks, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "track",
+ Artists: joinArtists(item.Artists),
+ AlbumName: item.Album.Name,
+ Images: firstImageURL(item.Album.Images),
+ ReleaseDate: item.Album.ReleaseDate,
+ ExternalURL: item.ExternalURL.Spotify,
+ Duration: item.DurationMS,
+ })
+ }
+
+ // Fetch albums
+ var albumsResp searchAlbumsResponse
+ if err := c.getJSON(ctx, searchURL, token, &albumsResp); err == nil {
+ for _, item := range albumsResp.Albums.Items {
+ response.Albums = append(response.Albums, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "album",
+ Artists: joinArtists(item.Artists),
+ Images: firstImageURL(item.Images),
+ ReleaseDate: item.ReleaseDate,
+ ExternalURL: item.ExternalURL.Spotify,
+ TotalTracks: item.TotalTracks,
+ })
+ }
+ }
+
+ // Fetch artists
+ var artistsResp searchArtistsResponse
+ if err := c.getJSON(ctx, searchURL, token, &artistsResp); err == nil {
+ for _, item := range artistsResp.Artists.Items {
+ response.Artists = append(response.Artists, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "artist",
+ Images: firstImageURL(item.Images),
+ ExternalURL: item.ExternalURL.Spotify,
+ })
+ }
+ }
+
+ // Fetch playlists
+ var playlistsResp searchPlaylistsResponse
+ if err := c.getJSON(ctx, searchURL, token, &playlistsResp); err == nil {
+ for _, item := range playlistsResp.Playlists.Items {
+ response.Playlists = append(response.Playlists, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "playlist",
+ Images: firstImageURL(item.Images),
+ ExternalURL: item.ExternalURL.Spotify,
+ Owner: item.Owner.DisplayName,
+ TotalTracks: item.Tracks.Total,
+ })
+ }
+ }
+
+ return response, nil
+}
+
+// SearchSpotify is a convenience wrapper for the Search method
+func SearchSpotify(ctx context.Context, query string, limit int) (*SearchResponse, error) {
+ client := NewSpotifyMetadataClient()
+ return client.Search(ctx, query, limit)
+}
+
+// SearchByType searches for a specific type (track, album, artist, playlist) with offset support
+func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
+ if query == "" {
+ return nil, errors.New("search query cannot be empty")
+ }
+
+ if limit <= 0 || limit > 50 {
+ limit = 50
+ }
+
+ if offset < 0 || offset > 1000 {
+ offset = 0
+ }
+
+ token, err := c.getAccessToken(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get access token: %w", err)
+ }
+
+ encodedQuery := url.QueryEscape(query)
+ searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=%s&limit=%d&offset=%d", encodedQuery, searchType, limit, offset)
+
+ results := make([]SearchResult, 0)
+
+ switch searchType {
+ case "track":
+ var resp searchTracksResponse
+ if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
+ return nil, fmt.Errorf("search failed: %w", err)
+ }
+ for _, item := range resp.Tracks.Items {
+ results = append(results, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "track",
+ Artists: joinArtists(item.Artists),
+ AlbumName: item.Album.Name,
+ Images: firstImageURL(item.Album.Images),
+ ReleaseDate: item.Album.ReleaseDate,
+ ExternalURL: item.ExternalURL.Spotify,
+ Duration: item.DurationMS,
+ })
+ }
+ case "album":
+ var resp searchAlbumsResponse
+ if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
+ return nil, fmt.Errorf("search failed: %w", err)
+ }
+ for _, item := range resp.Albums.Items {
+ results = append(results, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "album",
+ Artists: joinArtists(item.Artists),
+ Images: firstImageURL(item.Images),
+ ReleaseDate: item.ReleaseDate,
+ ExternalURL: item.ExternalURL.Spotify,
+ TotalTracks: item.TotalTracks,
+ })
+ }
+ case "artist":
+ var resp searchArtistsResponse
+ if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
+ return nil, fmt.Errorf("search failed: %w", err)
+ }
+ for _, item := range resp.Artists.Items {
+ results = append(results, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "artist",
+ Images: firstImageURL(item.Images),
+ ExternalURL: item.ExternalURL.Spotify,
+ })
+ }
+ case "playlist":
+ var resp searchPlaylistsResponse
+ if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
+ return nil, fmt.Errorf("search failed: %w", err)
+ }
+ for _, item := range resp.Playlists.Items {
+ results = append(results, SearchResult{
+ ID: item.ID,
+ Name: item.Name,
+ Type: "playlist",
+ Images: firstImageURL(item.Images),
+ ExternalURL: item.ExternalURL.Spotify,
+ Owner: item.Owner.DisplayName,
+ TotalTracks: item.Tracks.Total,
+ })
+ }
+ default:
+ return nil, fmt.Errorf("invalid search type: %s", searchType)
+ }
+
+ return results, nil
+}
+
+// SearchSpotifyByType is a convenience wrapper for SearchByType
+func SearchSpotifyByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
+ client := NewSpotifyMetadataClient()
+ return client.SearchByType(ctx, query, searchType, limit, offset)
+}
diff --git a/backend/tidal.go b/backend/tidal.go
index 3b72f88..cedb560 100644
--- a/backend/tidal.go
+++ b/backend/tidal.go
@@ -128,41 +128,25 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
- // Decode base64 API URL
- apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==")
-
- // Add cache-busting parameter with current timestamp
- urlWithCacheBust := fmt.Sprintf("%s?t=%d", string(apiURL), time.Now().Unix())
-
- // Create request with cache bypass headers
- req, err := http.NewRequest("GET", urlWithCacheBust, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- // Add headers to bypass cache
- req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
- req.Header.Set("Pragma", "no-cache")
- req.Header.Set("Expires", "0")
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch API list: %w", err)
- }
- 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)
+ // Hardcoded API URLs (base64 encoded for obfuscation)
+ encodedAPIs := []string{
+ "dm9nZWwucXFkbC5zaXRl", // API 1
+ "bWF1cy5xcWRsLnNpdGU=", // API 2
+ "aHVuZC5xcWRsLnNpdGU=", // API 3
+ "a2F0emUucXFkbC5zaXRl", // API 4
+ "d29sZi5xcWRsLnNpdGU=", // API 5
+ "dGlkYWwua2lub3BsdXMub25saW5l", // API 6
+ "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7
+ "dHJpdG9uLnNxdWlkLnd0Zg==", // API 8
}
var apis []string
- for _, api := range apiList {
- apis = append(apis, "https://"+api)
+ for _, encoded := range encodedAPIs {
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ continue
+ }
+ apis = append(apis, "https://"+string(decoded))
}
return apis, nil
@@ -834,6 +818,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
// Sanitize for filename only (not for metadata)
artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle)
+ albumTitleForFile := sanitizeFilename(albumTitle)
+ albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already 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)
- 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)
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)
artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle)
+ albumTitleForFile := sanitizeFilename(albumTitle)
+ albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
@@ -954,7 +942,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
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)
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)
finalArtistNameForFile := sanitizeFilename(finalArtistName)
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
+ finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
+ finalAlbumArtistForFile := sanitizeFilename(albumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
@@ -1089,7 +1079,7 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
}
// 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)
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)
finalArtistNameForFile := sanitizeFilename(finalArtistName)
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
+ finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
+ finalAlbumArtistForFile := sanitizeFilename(albumArtist)
// Check if file already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
@@ -1412,7 +1404,7 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
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)
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)
}
-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
// Determine track number to use
@@ -1520,11 +1512,27 @@ func buildTidalFilename(title, artist string, trackNumber int, format string, in
numberToUse = trackNumber
}
+ // Extract year from release date (format: YYYY-MM-DD or YYYY)
+ year := ""
+ if len(releaseDate) >= 4 {
+ year = releaseDate[:4]
+ }
+
// Check if format is a template (contains {})
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist)
+ filename = strings.ReplaceAll(filename, "{album}", album)
+ filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
+ filename = strings.ReplaceAll(filename, "{year}", year)
+
+ // Handle disc number
+ if discNumber > 0 {
+ filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+ } else {
+ filename = strings.ReplaceAll(filename, "{disc}", "")
+ }
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
diff --git a/frontend/index.html b/frontend/index.html
index ac5d143..a2bcfa8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -6,7 +6,7 @@
-
+
SpotiFLAC
diff --git a/frontend/package.json b/frontend/package.json
index eb4b455..8c049f8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -27,7 +27,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
- "motion": "^12.12.1",
+ "motion": "^12.23.26",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
@@ -48,7 +48,7 @@
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
- "typescript-eslint": "^8.50.0",
+ "typescript-eslint": "^8.50.1",
"vite": "^7.3.0"
}
}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index c5aa5ec..a252310 100644
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-c94dda3302d3338d7909ef5d634d0fde
\ No newline at end of file
+0f9764c2a4597a75120d3e76c32af7a9
\ No newline at end of file
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 20a18bd..a5f754e 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -54,7 +54,7 @@ importers:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
motion:
- specifier: ^12.12.1
+ specifier: ^12.23.26
version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-themes:
specifier: ^0.4.6
@@ -112,8 +112,8 @@ importers:
specifier: ~5.9.3
version: 5.9.3
typescript-eslint:
- specifier: ^8.50.0
- version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ specifier: ^8.50.1
+ version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.3.0
version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
@@ -1026,113 +1026,113 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
- '@rollup/rollup-android-arm-eabi@4.53.5':
- resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
+ '@rollup/rollup-android-arm-eabi@4.54.0':
+ resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
cpu: [arm]
os: [android]
- '@rollup/rollup-android-arm64@4.53.5':
- resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==}
+ '@rollup/rollup-android-arm64@4.54.0':
+ resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.53.5':
- resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==}
+ '@rollup/rollup-darwin-arm64@4.54.0':
+ resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.53.5':
- resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==}
+ '@rollup/rollup-darwin-x64@4.54.0':
+ resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-freebsd-arm64@4.53.5':
- resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==}
+ '@rollup/rollup-freebsd-arm64@4.54.0':
+ resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
cpu: [arm64]
os: [freebsd]
- '@rollup/rollup-freebsd-x64@4.53.5':
- resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==}
+ '@rollup/rollup-freebsd-x64@4.54.0':
+ resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
cpu: [x64]
os: [freebsd]
- '@rollup/rollup-linux-arm-gnueabihf@4.53.5':
- resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==}
+ '@rollup/rollup-linux-arm-gnueabihf@4.54.0':
+ resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.53.5':
- resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==}
+ '@rollup/rollup-linux-arm-musleabihf@4.54.0':
+ resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm64-gnu@4.53.5':
- resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==}
+ '@rollup/rollup-linux-arm64-gnu@4.54.0':
+ resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-arm64-musl@4.53.5':
- resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==}
+ '@rollup/rollup-linux-arm64-musl@4.54.0':
+ resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-loong64-gnu@4.53.5':
- resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==}
+ '@rollup/rollup-linux-loong64-gnu@4.54.0':
+ resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
- '@rollup/rollup-linux-ppc64-gnu@4.53.5':
- resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==}
+ '@rollup/rollup-linux-ppc64-gnu@4.54.0':
+ resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
- '@rollup/rollup-linux-riscv64-gnu@4.53.5':
- resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==}
+ '@rollup/rollup-linux-riscv64-gnu@4.54.0':
+ resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-riscv64-musl@4.53.5':
- resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==}
+ '@rollup/rollup-linux-riscv64-musl@4.54.0':
+ resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-s390x-gnu@4.53.5':
- resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==}
+ '@rollup/rollup-linux-s390x-gnu@4.54.0':
+ resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
- '@rollup/rollup-linux-x64-gnu@4.53.5':
- resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==}
+ '@rollup/rollup-linux-x64-gnu@4.54.0':
+ resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-linux-x64-musl@4.53.5':
- resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==}
+ '@rollup/rollup-linux-x64-musl@4.54.0':
+ resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-openharmony-arm64@4.53.5':
- resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==}
+ '@rollup/rollup-openharmony-arm64@4.54.0':
+ resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
cpu: [arm64]
os: [openharmony]
- '@rollup/rollup-win32-arm64-msvc@4.53.5':
- resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==}
+ '@rollup/rollup-win32-arm64-msvc@4.54.0':
+ resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.53.5':
- resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==}
+ '@rollup/rollup-win32-ia32-msvc@4.54.0':
+ resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
cpu: [ia32]
os: [win32]
- '@rollup/rollup-win32-x64-gnu@4.53.5':
- resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==}
+ '@rollup/rollup-win32-x64-gnu@4.54.0':
+ resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
cpu: [x64]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.53.5':
- resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==}
+ '@rollup/rollup-win32-x64-msvc@4.54.0':
+ resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
cpu: [x64]
os: [win32]
@@ -1255,63 +1255,63 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
- '@typescript-eslint/eslint-plugin@8.50.0':
- resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==}
+ '@typescript-eslint/eslint-plugin@8.50.1':
+ resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.50.0
+ '@typescript-eslint/parser': ^8.50.1
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/parser@8.50.0':
- resolution: {integrity: sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==}
+ '@typescript-eslint/parser@8.50.1':
+ resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/project-service@8.50.0':
- resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==}
+ '@typescript-eslint/project-service@8.50.1':
+ resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/scope-manager@8.50.0':
- resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==}
+ '@typescript-eslint/scope-manager@8.50.1':
+ resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.50.0':
- resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==}
+ '@typescript-eslint/tsconfig-utils@8.50.1':
+ resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/type-utils@8.50.0':
- resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==}
+ '@typescript-eslint/type-utils@8.50.1':
+ resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/types@8.50.0':
- resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==}
+ '@typescript-eslint/types@8.50.1':
+ resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.50.0':
- resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==}
+ '@typescript-eslint/typescript-estree@8.50.1':
+ resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/utils@8.50.0':
- resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==}
+ '@typescript-eslint/utils@8.50.1':
+ resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/visitor-keys@8.50.0':
- resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==}
+ '@typescript-eslint/visitor-keys@8.50.1':
+ resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@5.1.2':
@@ -1347,8 +1347,8 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
- baseline-browser-mapping@2.9.10:
- resolution: {integrity: sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==}
+ baseline-browser-mapping@2.9.11:
+ resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
hasBin: true
brace-expansion@1.1.12:
@@ -1366,8 +1366,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001760:
- resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
+ caniuse-lite@1.0.30001761:
+ resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -1866,8 +1866,8 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
- rollup@4.53.5:
- resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==}
+ rollup@4.54.0:
+ resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -1943,8 +1943,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
- typescript-eslint@8.50.0:
- resolution: {integrity: sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==}
+ typescript-eslint@8.50.1:
+ resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -2865,70 +2865,70 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.53': {}
- '@rollup/rollup-android-arm-eabi@4.53.5':
+ '@rollup/rollup-android-arm-eabi@4.54.0':
optional: true
- '@rollup/rollup-android-arm64@4.53.5':
+ '@rollup/rollup-android-arm64@4.54.0':
optional: true
- '@rollup/rollup-darwin-arm64@4.53.5':
+ '@rollup/rollup-darwin-arm64@4.54.0':
optional: true
- '@rollup/rollup-darwin-x64@4.53.5':
+ '@rollup/rollup-darwin-x64@4.54.0':
optional: true
- '@rollup/rollup-freebsd-arm64@4.53.5':
+ '@rollup/rollup-freebsd-arm64@4.54.0':
optional: true
- '@rollup/rollup-freebsd-x64@4.53.5':
+ '@rollup/rollup-freebsd-x64@4.54.0':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.53.5':
+ '@rollup/rollup-linux-arm-gnueabihf@4.54.0':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.53.5':
+ '@rollup/rollup-linux-arm-musleabihf@4.54.0':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.53.5':
+ '@rollup/rollup-linux-arm64-gnu@4.54.0':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.53.5':
+ '@rollup/rollup-linux-arm64-musl@4.54.0':
optional: true
- '@rollup/rollup-linux-loong64-gnu@4.53.5':
+ '@rollup/rollup-linux-loong64-gnu@4.54.0':
optional: true
- '@rollup/rollup-linux-ppc64-gnu@4.53.5':
+ '@rollup/rollup-linux-ppc64-gnu@4.54.0':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.53.5':
+ '@rollup/rollup-linux-riscv64-gnu@4.54.0':
optional: true
- '@rollup/rollup-linux-riscv64-musl@4.53.5':
+ '@rollup/rollup-linux-riscv64-musl@4.54.0':
optional: true
- '@rollup/rollup-linux-s390x-gnu@4.53.5':
+ '@rollup/rollup-linux-s390x-gnu@4.54.0':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.53.5':
+ '@rollup/rollup-linux-x64-gnu@4.54.0':
optional: true
- '@rollup/rollup-linux-x64-musl@4.53.5':
+ '@rollup/rollup-linux-x64-musl@4.54.0':
optional: true
- '@rollup/rollup-openharmony-arm64@4.53.5':
+ '@rollup/rollup-openharmony-arm64@4.54.0':
optional: true
- '@rollup/rollup-win32-arm64-msvc@4.53.5':
+ '@rollup/rollup-win32-arm64-msvc@4.54.0':
optional: true
- '@rollup/rollup-win32-ia32-msvc@4.53.5':
+ '@rollup/rollup-win32-ia32-msvc@4.54.0':
optional: true
- '@rollup/rollup-win32-x64-gnu@4.53.5':
+ '@rollup/rollup-win32-x64-gnu@4.54.0':
optional: true
- '@rollup/rollup-win32-x64-msvc@4.53.5':
+ '@rollup/rollup-win32-x64-msvc@4.54.0':
optional: true
'@tailwindcss/node@4.1.18':
@@ -3036,14 +3036,14 @@ snapshots:
dependencies:
csstype: 3.2.3
- '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.50.0
- '@typescript-eslint/type-utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.50.0
+ '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.50.1
+ '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.50.1
eslint: 9.39.2(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
@@ -3052,41 +3052,41 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.50.0
- '@typescript-eslint/types': 8.50.0
- '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.50.0
+ '@typescript-eslint/scope-manager': 8.50.1
+ '@typescript-eslint/types': 8.50.1
+ '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.50.1
debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)':
+ '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3)
- '@typescript-eslint/types': 8.50.0
+ '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.50.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.50.0':
+ '@typescript-eslint/scope-manager@8.50.1':
dependencies:
- '@typescript-eslint/types': 8.50.0
- '@typescript-eslint/visitor-keys': 8.50.0
+ '@typescript-eslint/types': 8.50.1
+ '@typescript-eslint/visitor-keys': 8.50.1
- '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.9.3)':
+ '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.50.0
- '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/types': 8.50.1
+ '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1)
ts-api-utils: 2.1.0(typescript@5.9.3)
@@ -3094,14 +3094,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.50.0': {}
+ '@typescript-eslint/types@8.50.1': {}
- '@typescript-eslint/typescript-estree@8.50.0(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.50.0(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3)
- '@typescript-eslint/types': 8.50.0
- '@typescript-eslint/visitor-keys': 8.50.0
+ '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.50.1
+ '@typescript-eslint/visitor-keys': 8.50.1
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
@@ -3111,20 +3111,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1))
- '@typescript-eslint/scope-manager': 8.50.0
- '@typescript-eslint/types': 8.50.0
- '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.50.1
+ '@typescript-eslint/types': 8.50.1
+ '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.50.0':
+ '@typescript-eslint/visitor-keys@8.50.1':
dependencies:
- '@typescript-eslint/types': 8.50.0
+ '@typescript-eslint/types': 8.50.1
eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
@@ -3164,7 +3164,7 @@ snapshots:
balanced-match@1.0.2: {}
- baseline-browser-mapping@2.9.10: {}
+ baseline-browser-mapping@2.9.11: {}
brace-expansion@1.1.12:
dependencies:
@@ -3177,15 +3177,15 @@ snapshots:
browserslist@4.28.1:
dependencies:
- baseline-browser-mapping: 2.9.10
- caniuse-lite: 1.0.30001760
+ baseline-browser-mapping: 2.9.11
+ caniuse-lite: 1.0.30001761
electron-to-chromium: 1.5.267
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
callsites@3.1.0: {}
- caniuse-lite@1.0.30001760: {}
+ caniuse-lite@1.0.30001761: {}
chalk@4.1.2:
dependencies:
@@ -3634,32 +3634,32 @@ snapshots:
resolve-from@4.0.0: {}
- rollup@4.53.5:
+ rollup@4.54.0:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.53.5
- '@rollup/rollup-android-arm64': 4.53.5
- '@rollup/rollup-darwin-arm64': 4.53.5
- '@rollup/rollup-darwin-x64': 4.53.5
- '@rollup/rollup-freebsd-arm64': 4.53.5
- '@rollup/rollup-freebsd-x64': 4.53.5
- '@rollup/rollup-linux-arm-gnueabihf': 4.53.5
- '@rollup/rollup-linux-arm-musleabihf': 4.53.5
- '@rollup/rollup-linux-arm64-gnu': 4.53.5
- '@rollup/rollup-linux-arm64-musl': 4.53.5
- '@rollup/rollup-linux-loong64-gnu': 4.53.5
- '@rollup/rollup-linux-ppc64-gnu': 4.53.5
- '@rollup/rollup-linux-riscv64-gnu': 4.53.5
- '@rollup/rollup-linux-riscv64-musl': 4.53.5
- '@rollup/rollup-linux-s390x-gnu': 4.53.5
- '@rollup/rollup-linux-x64-gnu': 4.53.5
- '@rollup/rollup-linux-x64-musl': 4.53.5
- '@rollup/rollup-openharmony-arm64': 4.53.5
- '@rollup/rollup-win32-arm64-msvc': 4.53.5
- '@rollup/rollup-win32-ia32-msvc': 4.53.5
- '@rollup/rollup-win32-x64-gnu': 4.53.5
- '@rollup/rollup-win32-x64-msvc': 4.53.5
+ '@rollup/rollup-android-arm-eabi': 4.54.0
+ '@rollup/rollup-android-arm64': 4.54.0
+ '@rollup/rollup-darwin-arm64': 4.54.0
+ '@rollup/rollup-darwin-x64': 4.54.0
+ '@rollup/rollup-freebsd-arm64': 4.54.0
+ '@rollup/rollup-freebsd-x64': 4.54.0
+ '@rollup/rollup-linux-arm-gnueabihf': 4.54.0
+ '@rollup/rollup-linux-arm-musleabihf': 4.54.0
+ '@rollup/rollup-linux-arm64-gnu': 4.54.0
+ '@rollup/rollup-linux-arm64-musl': 4.54.0
+ '@rollup/rollup-linux-loong64-gnu': 4.54.0
+ '@rollup/rollup-linux-ppc64-gnu': 4.54.0
+ '@rollup/rollup-linux-riscv64-gnu': 4.54.0
+ '@rollup/rollup-linux-riscv64-musl': 4.54.0
+ '@rollup/rollup-linux-s390x-gnu': 4.54.0
+ '@rollup/rollup-linux-x64-gnu': 4.54.0
+ '@rollup/rollup-linux-x64-musl': 4.54.0
+ '@rollup/rollup-openharmony-arm64': 4.54.0
+ '@rollup/rollup-win32-arm64-msvc': 4.54.0
+ '@rollup/rollup-win32-ia32-msvc': 4.54.0
+ '@rollup/rollup-win32-x64-gnu': 4.54.0
+ '@rollup/rollup-win32-x64-msvc': 4.54.0
fsevents: 2.3.3
scheduler@0.27.0: {}
@@ -3741,12 +3741,12 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- typescript-eslint@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
+ typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
@@ -3787,7 +3787,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
- rollup: 4.53.5
+ rollup: 4.54.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.0.3
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9c7864a..6174a3a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -9,9 +9,9 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
-import { Search, X } from "lucide-react";
+import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
-import { getSettings, applyThemeMode, applyFont } from "@/lib/settings";
+import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -55,9 +55,11 @@ function App() {
const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState(null);
const [fetchHistory, setFetchHistory] = useState([]);
+ const [isSearchMode, setIsSearchMode] = useState(false);
+ const [showScrollTop, setShowScrollTop] = useState(false);
const ITEMS_PER_PAGE = 50;
- const CURRENT_VERSION = "6.9";
+ const CURRENT_VERSION = "7.0";
const download = useDownload();
const metadata = useMetadata();
@@ -68,10 +70,19 @@ function App() {
useEffect(() => {
- const settings = getSettings();
- applyThemeMode(settings.themeMode);
- applyTheme(settings.theme);
- applyFont(settings.fontFamily);
+ const initSettings = async () => {
+ const settings = getSettings();
+ applyThemeMode(settings.themeMode);
+ applyTheme(settings.theme);
+ applyFont(settings.fontFamily);
+
+ // Initialize default download path if not set
+ if (!settings.downloadPath) {
+ const settingsWithDefaults = await getSettingsWithDefaults();
+ saveSettings(settingsWithDefaults);
+ }
+ };
+ initSettings();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
@@ -86,11 +97,22 @@ function App() {
checkForUpdates();
loadHistory();
+ // Scroll listener for jump to top button
+ const handleScroll = () => {
+ setShowScrollTop(window.scrollY > 300);
+ };
+ window.addEventListener("scroll", handleScroll);
+
return () => {
mediaQuery.removeEventListener("change", handleChange);
+ window.removeEventListener("scroll", handleScroll);
};
}, []);
+ const scrollToTop = useCallback(() => {
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }, []);
+
useEffect(() => {
setSelectedTracks([]);
setSearchQuery("");
@@ -282,10 +304,17 @@ function App() {
checkingAvailability={availability.checkingTrackId === track.spotify_id}
availability={availability.getAvailability(track.spotify_id || "")}
downloadingCover={cover.downloadingCover}
+ downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")}
+ failedCover={cover.failedCovers.has(track.spotify_id || "")}
+ skippedCover={cover.skippedCovers.has(track.spotify_id || "")}
onDownload={download.handleDownloadTrack}
- onDownloadLyrics={lyrics.handleDownloadLyrics}
+ onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) =>
+ lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)
+ }
onCheckAvailability={availability.checkAvailability}
- onDownloadCover={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}
/>
);
@@ -327,11 +356,11 @@ function App() {
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
- onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
- lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position)
+ onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
+ lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber)
}
- onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
- cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, 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, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
@@ -395,11 +424,11 @@ function App() {
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
- onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
- lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position)
+ onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
+ lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)
}
- onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
- cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, 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, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)}
@@ -469,11 +498,11 @@ function App() {
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
- onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
- lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position)
+ onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
+ lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)
}
- onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
- cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, 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, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)}
@@ -629,13 +658,22 @@ function App() {
loading={metadata.loading}
onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata}
+ onFetchUrl={async (url) => {
+ setSpotifyUrl(url);
+ const updatedUrl = await metadata.handleFetchMetadata(url);
+ if (updatedUrl) {
+ setSpotifyUrl(updatedUrl);
+ }
+ }}
history={fetchHistory}
onHistorySelect={handleHistorySelect}
onHistoryRemove={removeFromHistory}
hasResult={!!metadata.metadata}
+ searchMode={isSearchMode}
+ onSearchModeChange={setIsSearchMode}
/>
- {metadata.metadata && renderMetadata()}
+ {!isSearchMode && metadata.metadata && renderMetadata()}
>
);
}
@@ -662,6 +700,17 @@ function App() {
isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue}
/>
+
+ {/* Jump to Top Button - Bottom Right */}
+ {showScrollTop && (
+
+
+
+ )}
);
diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx
index 6fa3fd3..b16a2f7 100644
--- a/frontend/src/components/AlbumInfo.tsx
+++ b/frontend/src/components/AlbumInfo.tsx
@@ -52,8 +52,8 @@ interface AlbumInfoProps {
onToggleTrack: (isrc: string) => 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;
- onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
- onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
+ onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
+ onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx
index bebc1d2..c8678dd 100644
--- a/frontend/src/components/ArtistInfo.tsx
+++ b/frontend/src/components/ArtistInfo.tsx
@@ -57,8 +57,8 @@ interface ArtistInfoProps {
onToggleTrack: (isrc: string) => 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;
- onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
- onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
+ onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
+ onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx
index 1691295..ee4c19c 100644
--- a/frontend/src/components/FileManagerPage.tsx
+++ b/frontend/src/components/FileManagerPage.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
-import { Input } from "@/components/ui/input";
+import { InputWithContext } from "@/components/ui/input-with-context";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
@@ -21,14 +21,26 @@ import {
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";
-// These functions will be available after Wails regenerates bindings
-// For now, we call them directly via window.go
const ListDirectoryFiles = (path: string): Promise =>
(window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise =>
@@ -41,16 +53,12 @@ const IsFFprobeInstalled = (): Promise =>
(window as any)['go']['main']['App']['IsFFprobeInstalled']();
const DownloadFFmpeg = (): Promise<{ success: boolean; message: string; error?: string }> =>
(window as any)['go']['main']['App']['DownloadFFmpeg']();
-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 ReadTextFile = (path: string): Promise =>
+ (window as any)['go']['main']['App']['ReadTextFile'](path);
+const RenameFileTo = (oldPath: string, newName: string): Promise =>
+ (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
+const ReadImageAsBase64 = (path: string): Promise =>
+ (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
interface FileNode {
name: string;
@@ -59,7 +67,6 @@ interface FileNode {
size: number;
children?: FileNode[];
expanded?: boolean;
- selected?: boolean;
}
interface FileMetadata {
@@ -72,6 +79,8 @@ interface FileMetadata {
year: string;
}
+type TabType = "track" | "lyric" | "cover";
+
const FORMAT_PRESETS: Record = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
@@ -89,6 +98,8 @@ const FORMAT_PRESETS: Record = {
};
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";
@@ -97,46 +108,39 @@ function formatFileSize(bytes: number): string {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
-const DEFAULT_PRESET = "title-artist";
-const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
export function FileManagerPage() {
const [rootPath, setRootPath] = useState(() => {
const settings = getSettings();
return settings.downloadPath || "";
});
- const [files, setFiles] = useState([]);
+ const [allFiles, setAllFiles] = useState([]);
const [selectedFiles, setSelectedFiles] = useState>(new Set());
const [loading, setLoading] = useState(false);
+ const [activeTab, setActiveTab] = useState("track");
const [formatPreset, setFormatPreset] = useState(() => {
try {
- const saved = sessionStorage.getItem(STORAGE_KEY);
+ const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
return parsed.formatPreset;
}
}
- } catch (err) {
- // Ignore
- }
+ } catch { /* ignore */ }
return DEFAULT_PRESET;
});
const [customFormat, setCustomFormat] = useState(() => {
try {
- const saved = sessionStorage.getItem(STORAGE_KEY);
+ const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
- if (parsed.customFormat) {
- return parsed.customFormat;
- }
+ if (parsed.customFormat) return parsed.customFormat;
}
- } catch (err) {
- // Ignore
- }
+ } 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([]);
@@ -150,58 +154,76 @@ export function FileManagerPage() {
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);
- // Save state to sessionStorage
useEffect(() => {
try {
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
- } catch (err) {
- console.error("Failed to save state:", err);
- }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
+ } catch { /* ignore */ }
}, [formatPreset, customFormat]);
- // Detect fullscreen/maximized window
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);
- // Handle null/undefined result (can happen on Linux)
if (!result || !Array.isArray(result)) {
- setFiles([]);
+ setAllFiles([]);
setSelectedFiles(new Set());
return;
}
- // Filter to only show audio files and folders containing audio files
- const filtered = filterAudioFiles(result as FileNode[]);
- setFiles(filtered);
+ setAllFiles(result as FileNode[]);
setSelectedFiles(new Set());
} catch (err) {
- // Don't show error toast for empty directory or no files found
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",
- });
+ toast.error("Failed to load files", { description: errorMsg || "Unknown error" });
}
- setFiles([]);
+ setAllFiles([]);
setSelectedFiles(new Set());
} finally {
setLoading(false);
@@ -209,92 +231,67 @@ export function FileManagerPage() {
}, [rootPath]);
useEffect(() => {
- if (rootPath) {
- loadFiles();
- }
+ if (rootPath) loadFiles();
}, [rootPath, loadFiles]);
- const filterAudioFiles = (nodes: FileNode[]): FileNode[] => {
- return nodes
- .map((node) => {
- if (node.is_dir && node.children) {
- const filteredChildren = filterAudioFiles(node.children);
- if (filteredChildren.length > 0) {
- return { ...node, children: filteredChildren };
- }
- return null;
- }
- const ext = node.name.toLowerCase();
- if (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")) {
- return node;
- }
- return null;
- })
- .filter((node): node is FileNode => node !== null);
+ 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);
- }
+ if (path) setRootPath(path);
} catch (err) {
- toast.error("Failed to select folder", {
- description: err instanceof Error ? err.message : "Unknown error",
- });
+ toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const toggleExpand = (path: string) => {
- setFiles((prev) => toggleNodeExpand(prev, path));
+ 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) };
- }
+ 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, isDir: boolean) => {
- if (isDir) return;
-
+ const toggleSelect = (path: string) => {
setSelectedFiles((prev) => {
const newSet = new Set(prev);
- if (newSet.has(path)) {
- newSet.delete(path);
- } else {
- newSet.add(path);
- }
+ if (newSet.has(path)) newSet.delete(path);
+ else newSet.add(path);
return newSet;
});
};
const toggleFolderSelect = (node: FileNode) => {
- const folderFiles = getAllAudioFiles([node]);
+ const folderFiles = getAllFilesFlat([node]);
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
-
setSelectedFiles((prev) => {
const newSet = new Set(prev);
- if (allSelected) {
- // Deselect all files in folder
- folderFiles.forEach((f) => newSet.delete(f.path));
- } else {
- // Select all files in folder
- folderFiles.forEach((f) => newSet.add(f.path));
- }
+ 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 = getAllAudioFiles([node]);
+ 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;
@@ -302,74 +299,33 @@ export function FileManagerPage() {
return "indeterminate";
};
- const selectAll = () => {
- const allAudioFiles = getAllAudioFiles(files);
- setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
- };
-
- const deselectAll = () => {
- setSelectedFiles(new Set());
- };
-
- const getAllAudioFiles = (nodes: FileNode[]): FileNode[] => {
- const result: FileNode[] = [];
- for (const node of nodes) {
- if (!node.is_dir) {
- result.push(node);
- }
- if (node.children) {
- result.push(...getAllAudioFiles(node.children));
- }
- }
- return result;
- };
-
- const resetToDefault = () => {
- setFormatPreset(DEFAULT_PRESET);
- setCustomFormat(DEFAULT_CUSTOM_FORMAT);
- setShowResetConfirm(false);
- };
+ 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;
- }
-
- // Check if any selected file is M4A and ffprobe is not installed
+ 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;
- }
+ 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",
- });
+ toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
-
- // Check if M4A file needs ffprobe
if (filePath.toLowerCase().endsWith(".m4a")) {
const installed = await IsFFprobeInstalled();
- if (!installed) {
- setShowFFprobeDialog(true);
- return;
- }
+ if (!installed) { setShowFFprobeDialog(true); return; }
}
-
setMetadataFile(filePath);
setLoadingMetadata(true);
try {
@@ -377,9 +333,7 @@ export function FileManagerPage() {
setMetadataInfo(metadata as FileMetadata);
setShowMetadata(true);
} catch (err) {
- toast.error("Failed to read metadata", {
- description: err instanceof Error ? err.message : "Unknown error",
- });
+ toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" });
setMetadataInfo(null);
} finally {
setLoadingMetadata(false);
@@ -390,110 +344,132 @@ export function FileManagerPage() {
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,
- });
- }
+ 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",
- });
+ 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`,
- });
- }
-
+ 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",
- });
+ toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" });
} finally {
setRenaming(false);
}
};
- const renderFileTree = (nodes: FileNode[], depth = 0) => {
+ const renderTrackTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
(node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path, node.is_dir))}
+ onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}
>
{node.is_dir ? (
<>
{
- if (el) {
- (el as HTMLButtonElement).dataset.state =
- isFolderSelected(node) === "indeterminate" ? "indeterminate" :
- isFolderSelected(node) ? "checked" : "unchecked";
- }
- }}
+ 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 ? (
-
- ) : (
-
- )}
+ {node.expanded ? : }
>
) : (
<>
- toggleSelect(node.path, node.is_dir)}
- onClick={(e) => e.stopPropagation()}
- className="shrink-0"
- />
+ toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0" />
>
)}
{node.name}
- {node.is_dir && ({getAllAudioFiles([node]).length}) }
+ {node.is_dir && ({getAllFilesFlat([node]).length}) }
{!node.is_dir && (
<>
{formatFileSize(node.size)}
- handleShowMetadata(node.path, e)}
- >
+ handleShowMetadata(node.path, e)}>
@@ -502,14 +478,105 @@ export function FileManagerPage() {
>
)}
- {node.is_dir && node.expanded && node.children && (
-
{renderFileTree(node.children, depth + 1)}
- )}
+ {node.is_dir && node.expanded && node.children &&
{renderTrackTree(node.children, depth + 1)}
}
+
+ ));
+ };
+
+ const renderLyricTree = (nodes: FileNode[], depth = 0) => {
+ return nodes.map((node) => (
+
+
node.is_dir && toggleExpand(node.path)}
+ >
+ {node.is_dir ? (
+ <>
+ {node.expanded ?
:
}
+
+ >
+ ) : (
+
+ )}
+
+ {node.name}
+ {node.is_dir && ({getAllFilesFlat([node]).length}) }
+
+ {!node.is_dir && (
+ <>
+
{formatFileSize(node.size)}
+
+
+ handleShowLyrics(node.path, e)}>
+
+
+
+ Preview
+
+
+
+ handleManualRename(node.path, e)}>
+
+
+
+ Rename
+
+ >
+ )}
+
+ {node.is_dir && node.expanded && node.children &&
{renderLyricTree(node.children, depth + 1)}
}
+
+ ));
+ };
+
+ const renderCoverTree = (nodes: FileNode[], depth = 0) => {
+ return nodes.map((node) => (
+
+
node.is_dir && toggleExpand(node.path)}
+ >
+ {node.is_dir ? (
+ <>
+ {node.expanded ?
:
}
+
+ >
+ ) : (
+
+ )}
+
+ {node.name}
+ {node.is_dir && ({getAllFilesFlat([node]).length}) }
+
+ {!node.is_dir && (
+ <>
+
{formatFileSize(node.size)}
+
+
+ handleShowCover(node.path, e)}>
+
+
+
+ Preview
+
+
+
+ handleManualRename(node.path, e)}>
+
+
+
+ Rename
+
+ >
+ )}
+
+ {node.is_dir && node.expanded && node.children &&
{renderCoverTree(node.children, depth + 1)}
}
));
};
- const allAudioFiles = getAllAudioFiles(files);
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
return (
@@ -520,12 +587,7 @@ export function FileManagerPage() {
{/* Path Selection */}
- setRootPath(e.target.value)}
- placeholder="Select a folder..."
- className="flex-1"
- />
+ setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1" />
Browse
@@ -536,95 +598,97 @@ export function FileManagerPage() {
- {/* Rename Format */}
-
-
-
Rename Format
-
-
-
-
-
- Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}
-
-
-
-
-
-
-
-
-
- {Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (
- {label}
- ))}
-
-
- {formatPreset === "custom" && (
- setCustomFormat(e.target.value)}
- placeholder="{artist} - {title}"
- className="flex-1"
- />
- )}
-
-
- setShowResetConfirm(true)}>
-
-
-
- Reset to Default
-
-
-
- Preview: {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
-
+ {/* Tabs */}
+
+ setActiveTab("track")} className="rounded-b-none">
+
+ Track ({allAudioFiles.length})
+
+ setActiveTab("lyric")} className="rounded-b-none">
+
+ Lyric ({allLyricFiles.length})
+
+ setActiveTab("cover")} className="rounded-b-none">
+
+ Cover ({allCoverFiles.length})
+
+ {/* Rename Format - Only for Track tab */}
+ {activeTab === "track" && (
+
+
+
Rename Format
+
+
+
+
+
+ Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}
+
+
+
+
+
+
+
+ {Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (
+ {label}
+ ))}
+
+
+ {formatPreset === "custom" && (
+ setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1" />
+ )}
+
+
+ setShowResetConfirm(true)}>
+
+
+
+ Reset to Default
+
+
+
+ Preview: {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
+
+
+ )}
+
{/* File Tree */}
-
-
-
- {allSelected ? "Deselect All" : "Select All"}
-
-
- {selectedFiles.size} of {allAudioFiles.length} file(s) selected
-
+ {activeTab === "track" && (
+
+
+
+ {allSelected ? "Deselect All" : "Select All"}
+
+ {selectedFiles.size} of {allAudioFiles.length} file(s) selected
+
+
+
handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
+
+ Preview
+
+
handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
+
+ Rename
+
+
-
-
handlePreview(true)}
- disabled={selectedFiles.size === 0 || loading}
- >
-
- Preview
-
-
handlePreview(false)}
- disabled={selectedFiles.size === 0 || loading}
- >
-
- Rename
-
-
-
+ )}
{loading ? (
-
-
-
- ) : files.length === 0 ? (
+
+ ) : filteredFiles.length === 0 ? (
- {rootPath ? "No audio files found" : "Select a folder to browse"}
+ {rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
) : (
- renderFileTree(files)
+ activeTab === "track" ? renderTrackTree(filteredFiles) :
+ activeTab === "lyric" ? renderLyricTree(filteredFiles) :
+ renderCoverTree(filteredFiles)
)}
@@ -634,9 +698,7 @@ export function FileManagerPage() {
Reset to Default?
-
- This will reset the rename format to "Title - Artist". Your custom format will be lost.
-
+ This will reset the rename format to "Title - Artist". Your custom format will be lost.
setShowResetConfirm(false)}>Cancel
@@ -650,50 +712,26 @@ export function FileManagerPage() {
Rename Preview
-
- Review the changes before renaming. Files with errors will be skipped.
-
+ Review the changes before renaming. Files with errors will be skipped.
-
{previewData.map((item, index) => (
-
+
{item.old_name}
- {item.error ? (
-
{item.error}
- ) : (
-
→ {item.new_name}
- )}
+ {item.error ?
{item.error}
:
→ {item.new_name}
}
))}
-
{previewOnly ? (
- setShowPreview(false)}>
- Close
-
+ setShowPreview(false)}>Close
) : (
<>
- setShowPreview(false)}>
- Cancel
-
+ setShowPreview(false)}>Cancel
- {renaming ? (
- <>
-
- Renaming...
- >
- ) : (
- <>
- Rename {previewData.filter((p) => !p.error).length} File(s)
- >
- )}
+ {renaming ? <> Renaming...> : <>Rename {previewData.filter((p) => !p.error).length} File(s)>}
>
)}
@@ -706,55 +744,24 @@ export function FileManagerPage() {
File Metadata
-
- {metadataFile.split(/[/\\]/).pop()}
-
+ {metadataFile.split(/[/\\]/).pop()}
-
{loadingMetadata ? (
-
-
-
+
) : metadataInfo ? (
-
- Title
- {metadataInfo.title || "-"}
-
-
- Artist
- {metadataInfo.artist || "-"}
-
-
- Album
- {metadataInfo.album || "-"}
-
-
- Album Artist
- {metadataInfo.album_artist || "-"}
-
-
- Track
- {metadataInfo.track_number || "-"}
-
-
- Disc
- {metadataInfo.disc_number || "-"}
-
-
- Year
- {metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
-
+
Title {metadataInfo.title || "-"}
+
Artist {metadataInfo.artist || "-"}
+
Album {metadataInfo.album || "-"}
+
Album Artist {metadataInfo.album_artist || "-"}
+
Track {metadataInfo.track_number || "-"}
+
Disc {metadataInfo.disc_number || "-"}
+
Year {metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
) : (
-
- No metadata available
-
+ No metadata available
)}
-
-
- setShowMetadata(false)}>Close
-
+ setShowMetadata(false)}>Close
@@ -763,23 +770,75 @@ export function FileManagerPage() {
FFprobe Required
-
- Reading M4A metadata requires FFprobe. Would you like to download and install it now?
-
+ Reading M4A metadata requires FFprobe. Would you like to download and install it now?
- setShowFFprobeDialog(false)} disabled={installingFFprobe}>
- Cancel
-
+ setShowFFprobeDialog(false)} disabled={installingFFprobe}>Cancel
- {installingFFprobe ? (
- <>
-
- Installing...
- >
- ) : (
- "Install FFprobe"
- )}
+ {installingFFprobe ? <> Installing...> : "Install FFprobe"}
+
+
+
+
+
+ {/* Lyrics Preview Dialog */}
+
+
+
+ Lyrics Preview
+ {lyricsFile.split(/[/\\]/).pop()}
+
+
+ setLyricsTab("synced")}>Synced
+ setLyricsTab("plain")}>Plain
+
+
+
+ {lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent) || "No lyrics content"}
+
+
+
+
+ {copySuccess ? : }
+ Copy
+
+ setShowLyricsPreview(false)}>Close
+
+
+
+
+ {/* Cover Preview Dialog */}
+
+
+
+ Cover Preview
+ {coverFile.split(/[/\\]/).pop()}
+
+
+ {coverData ?
:
Loading...
}
+
+ setShowCoverPreview(false)}>Close
+
+
+
+ {/* Manual Rename Dialog */}
+
+
+
+ Rename File
+ {manualRenameFile.split(/[/\\]/).pop()}
+
+
+
New Name
+
+ setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { if (e.key === "Enter" && !manualRenaming) handleConfirmManualRename(); }} />
+ {manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}
+
+
+
+ setShowManualRename(false)} disabled={manualRenaming}>Cancel
+
+ {manualRenaming ? <> Renaming...> : "Rename"}
diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx
index b5e6596..dcf8d65 100644
--- a/frontend/src/components/PlaylistInfo.tsx
+++ b/frontend/src/components/PlaylistInfo.tsx
@@ -56,8 +56,8 @@ interface PlaylistInfoProps {
onToggleTrack: (isrc: string) => 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;
- onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
- onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
+ onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
+ onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index afae4e3..1a2c12e 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -1,7 +1,7 @@
+import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
-import { Label } from "@/components/ui/label";
-import { CloudDownload, Info, XCircle } from "lucide-react";
+import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
@@ -10,16 +10,28 @@ import {
} from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory";
+import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
+import { backend } from "../../wailsjs/go/models";
+import { cn } from "@/lib/utils";
+
+type ResultTab = "tracks" | "albums" | "artists" | "playlists";
+
+const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
+const MAX_RECENT_SEARCHES = 8;
+const SEARCH_LIMIT = 50;
interface SearchBarProps {
url: string;
loading: boolean;
onUrlChange: (url: string) => void;
onFetch: () => void;
+ onFetchUrl: (url: string) => Promise;
history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void;
hasResult: boolean;
+ searchMode: boolean;
+ onSearchModeChange: (isSearch: boolean) => void;
}
export function SearchBar({
@@ -27,68 +39,513 @@ export function SearchBar({
loading,
onUrlChange,
onFetch,
+ onFetchUrl,
history,
onHistorySelect,
onHistoryRemove,
hasResult,
+ searchMode,
+ onSearchModeChange,
}: SearchBarProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState(null);
+ const [isSearching, setIsSearching] = useState(false);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+ const [lastSearchedQuery, setLastSearchedQuery] = useState("");
+ const [activeTab, setActiveTab] = useState("tracks");
+ const [recentSearches, setRecentSearches] = useState([]);
+ const [hasMore, setHasMore] = useState>({
+ tracks: false,
+ albums: false,
+ artists: false,
+ playlists: false,
+ });
+ const searchTimeoutRef = useRef | 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 = {
+ 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 (
-
+
-
Spotify URL
+ {/* Mode Toggle */}
+
+ 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"
+ )}
+ >
+
+ URL
+
+ 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
+
+
+
- Supports track, album, playlist, and artist URLs
- Note: Playlist must be public (not private)
+ {!searchMode ? (
+ <>
+ Supports track, album, playlist, and artist URLs
+ Note: Playlist must be public (not private)
+ >
+ ) : (
+ Search for tracks, albums, artists, or playlists
+ )}
+
- onUrlChange(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && onFetch()}
- className="pr-8"
- />
- {url && (
- onUrlChange("")}
- >
-
-
- )}
-
-
- {loading ? (
+ {!searchMode ? (
<>
-
- Fetching...
+ onUrlChange(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && onFetch()}
+ className="pr-8"
+ />
+ {url && (
+ onUrlChange("")}
+ >
+
+
+ )}
>
) : (
<>
-
- Fetch
+ setSearchQuery(e.target.value)}
+ className="pr-8"
+ />
+ {searchQuery && (
+ {
+ setSearchQuery("");
+ setSearchResults(null);
+ setLastSearchedQuery("");
+ }}
+ >
+
+
+ )}
>
)}
-
+
+
+ {!searchMode && (
+
+ {loading ? (
+ <>
+
+ Fetching...
+ >
+ ) : (
+ <>
+
+ Fetch
+ >
+ )}
+
+ )}
- {!hasResult && (
+
+ {!searchMode && !hasResult && (
)}
+
+ {/* Search Results with Tabs */}
+ {searchMode && (
+
+ {/* Recent Searches - show when no query or no results yet */}
+ {!searchQuery && !searchResults && recentSearches.length > 0 && (
+
+
Recent Searches
+
+ {recentSearches.map((query) => (
+
setSearchQuery(query)}
+ >
+ {query}
+ {
+ e.stopPropagation();
+ removeRecentSearch(query);
+ }}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ {isSearching && (
+
+
+ Searching...
+
+ )}
+
+ {!isSearching && searchQuery && !hasAnyResults && (
+
+ No results found for "{searchQuery}"
+
+ )}
+
+ {!isSearching && hasAnyResults && (
+ <>
+ {/* Tabs */}
+
+ {tabs.map((tab) => {
+ const count = getTabCount(tab.key);
+ if (count === 0) return null;
+ return (
+ 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})
+
+ );
+ })}
+
+
+ {/* Tab Content */}
+
+ {/* Tracks */}
+ {activeTab === "tracks" && searchResults?.tracks.map((track) => (
+
handleResultClick(track.external_urls)}
+ >
+ {track.images ? (
+
+ ) : (
+
+ )}
+
+
{track.name}
+
{track.artists}
+
+
+ {formatDuration(track.duration_ms || 0)}
+
+
+ ))}
+
+ {/* Albums */}
+ {activeTab === "albums" && searchResults?.albums.map((album) => (
+
handleResultClick(album.external_urls)}
+ >
+ {album.images ? (
+
+ ) : (
+
+ )}
+
+
{album.name}
+
{album.artists}
+
+
+ {album.total_tracks} tracks
+
+
+ ))}
+
+ {/* Artists */}
+ {activeTab === "artists" && searchResults?.artists.map((artist) => (
+
handleResultClick(artist.external_urls)}
+ >
+ {artist.images ? (
+
+ ) : (
+
+ )}
+
+
{artist.name}
+
Artist
+
+
+ ))}
+
+ {/* Playlists */}
+ {activeTab === "playlists" && searchResults?.playlists.map((playlist) => (
+
handleResultClick(playlist.external_urls)}
+ >
+ {playlist.images ? (
+
+ ) : (
+
+ )}
+
+
{playlist.name}
+
+ {playlist.owner} • {playlist.total_tracks} tracks
+
+
+
+ ))}
+
+
+ {/* Load More Button */}
+ {hasMore[activeTab] && (
+
+
+ {isLoadingMore ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ <>
+
+ Load More
+ >
+ )}
+
+
+ )}
+ >
+ )}
+
+ )}
);
}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx
index b75a677..0dfef1d 100644
--- a/frontend/src/components/SettingsPage.tsx
+++ b/frontend/src/components/SettingsPage.tsx
@@ -85,6 +85,8 @@ export function SettingsPage() {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
+ // Save to localStorage so it persists on reload
+ saveSettings(settingsWithDefaults);
}
};
loadDefaults();
diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx
index 7122304..be5570a 100644
--- a/frontend/src/components/TrackInfo.tsx
+++ b/frontend/src/components/TrackInfo.tsx
@@ -1,4 +1,3 @@
-import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
@@ -25,10 +24,13 @@ interface TrackInfoProps {
checkingAvailability?: boolean;
availability?: TrackAvailability;
downloadingCover?: boolean;
+ downloadedCover?: boolean;
+ failedCover?: boolean;
+ skippedCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
- onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
+ onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
- onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => 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;
}
@@ -46,51 +48,26 @@ export function TrackInfo({
checkingAvailability,
availability,
downloadingCover,
+ downloadedCover,
+ failedCover,
+ skippedCover,
onDownload,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onOpenFolder,
}: TrackInfoProps) {
- const [isHoveringCover, setIsHoveringCover] = useState(false);
-
return (
-
setIsHoveringCover(true)}
- onMouseLeave={() => setIsHoveringCover(false)}
- >
+
{track.images && (
- <>
-
- {isHoveringCover && onDownloadCover && (
-
-
-
- onDownloadCover(track.images, track.name, track.artists, track.album_name)}
- disabled={downloadingCover}
- >
- {downloadingCover ? : }
-
-
-
- Download Cover
-
-
-
- )}
- >
+
)}
@@ -136,7 +113,7 @@ export function TrackInfo({
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
+ onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)}
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
@@ -158,6 +135,32 @@ export function TrackInfo({
)}
+ {track.images && onDownloadCover && (
+
+
+ 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 ? (
+
+ ) : skippedCover ? (
+
+ ) : downloadedCover ? (
+
+ ) : failedCover ? (
+
+ ) : (
+
+ )}
+
+
+
+ Download Cover
+
+
+ )}
{track.spotify_id && onCheckAvailability && (
diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx
index 9e4f6de..78a31d2 100644
--- a/frontend/src/components/TrackList.tsx
+++ b/frontend/src/components/TrackList.tsx
@@ -50,9 +50,9 @@ interface TrackListProps {
onToggleTrack: (isrc: string) => 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;
- 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;
- 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;
onAlbumClick?: (album: { 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({
- 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"
variant="outline"
@@ -369,7 +369,7 @@ export function TrackList({
{
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"
variant="outline"
diff --git a/frontend/src/hooks/useCover.ts b/frontend/src/hooks/useCover.ts
index 5c9efb1..174fef9 100644
--- a/frontend/src/hooks/useCover.ts
+++ b/frontend/src/hooks/useCover.ts
@@ -23,7 +23,10 @@ export function useCover() {
albumName?: string,
playlistName?: string,
position?: number,
- trackId?: string
+ trackId?: string,
+ albumArtist?: string,
+ releaseDate?: string,
+ discNumber?: number
) => {
if (!coverUrl) {
toast.error("No cover URL found for this track");
@@ -72,10 +75,14 @@ export function useCover() {
cover_url: coverUrl,
track_name: trackName,
artist_name: artistName,
+ album_name: albumName || "",
+ album_artist: albumArtist || "",
+ release_date: releaseDate || "",
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
+ disc_number: discNumber || 0,
});
if (response.success) {
@@ -145,12 +152,16 @@ export function useCover() {
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
+ // Determine if we should use album track number or sequential position
+ const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
+ // Use track.track_number for album context, otherwise use sequential position (consistent with track download)
+ const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
- track: i + 1,
+ track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder),
};
@@ -176,10 +187,14 @@ export function useCover() {
cover_url: track.images,
track_name: track.name,
artist_name: track.artists,
+ album_name: track.album_name,
+ album_artist: track.album_artist,
+ release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
- position: i + 1,
+ position: trackPosition,
+ disc_number: track.disc_number,
});
if (response.success) {
diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts
index 96fc1b4..c5f786a 100644
--- a/frontend/src/hooks/useLyrics.ts
+++ b/frontend/src/hooks/useLyrics.ts
@@ -21,7 +21,10 @@ export function useLyrics() {
artistName: string,
albumName?: string,
playlistName?: string,
- position?: number
+ position?: number,
+ albumArtist?: string,
+ releaseDate?: string,
+ discNumber?: number
) => {
if (!spotifyId) {
toast.error("No Spotify ID found for this track");
@@ -71,11 +74,15 @@ export function useLyrics() {
spotify_id: spotifyId,
track_name: trackName,
artist_name: artistName,
+ album_name: albumName,
+ album_artist: albumArtist,
+ release_date: releaseDate,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
use_album_track_number: useAlbumTrackNumber,
+ disc_number: discNumber,
});
if (response.success) {
@@ -126,7 +133,8 @@ export function useLyrics() {
let skipped = 0;
const total = tracksWithSpotifyId.length;
- for (const track of tracksWithSpotifyId) {
+ for (let i = 0; i < tracksWithSpotifyId.length; i++) {
+ const track = tracksWithSpotifyId[i];
if (stopBulkDownloadRef.current) {
toast.info("Lyrics download stopped by user");
break;
@@ -142,12 +150,18 @@ export function useLyrics() {
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
+
+ // Determine if we should use album track number or sequential position
+ const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
+ // Use track.track_number for album context, otherwise use sequential position (consistent with track download)
+ const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
+
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
- track: track.track_number,
+ track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder),
};
@@ -169,17 +183,19 @@ export function useLyrics() {
}
}
- const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
-
const response = await downloadLyrics({
spotify_id: id,
track_name: track.name,
artist_name: track.artists,
+ album_name: track.album_name,
+ album_artist: track.album_artist,
+ release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
- position: track.track_number || 0,
+ position: trackPosition,
use_album_track_number: useAlbumTrackNumber,
+ disc_number: track.disc_number,
});
if (response.success) {
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 6d4855e..286a861 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -77,7 +77,7 @@
}
body {
@apply bg-background text-foreground;
- font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
code, pre, .font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts
index e858778..8f13716 100644
--- a/frontend/src/lib/settings.ts
+++ b/frontend/src/lib/settings.ts
@@ -112,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: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
- { value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
+ { value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 730d1f3..d76e7ad 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -185,11 +185,15 @@ export interface LyricsDownloadRequest {
spotify_id: string;
track_name: string;
artist_name: string;
+ album_name?: string;
+ album_artist?: string;
+ release_date?: string;
output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
use_album_track_number?: boolean;
+ disc_number?: number;
}
export interface LyricsDownloadResponse {
@@ -214,10 +218,14 @@ export interface CoverDownloadRequest {
cover_url: string;
track_name: string;
artist_name: string;
+ album_name?: string;
+ album_artist?: string;
+ release_date?: string;
output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
+ disc_number?: number;
}
export interface CoverDownloadResponse {
diff --git a/tidal.json b/tidal.json
deleted file mode 100644
index e9d91bb..0000000
--- a/tidal.json
+++ /dev/null
@@ -1,10 +0,0 @@
-[
- "vogel.qqdl.site",
- "maus.qqdl.site",
- "hund.qqdl.site",
- "katze.qqdl.site",
- "wolf.qqdl.site",
- "tidal.kinoplus.online",
- "tidal-api.binimum.org",
- "triton.squid.wtf"
-]
diff --git a/wails.json b/wails.json
index a55375a..8a1e29f 100644
--- a/wails.json
+++ b/wails.json
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
- "productVersion": "6.9"
+ "productVersion": "7.0"
},
"wailsjsdir": "./frontend",
"assetdir": "./frontend/dist",