From c15012427355c3a86abe6a4c6ecf3a7ed277ac56 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Wed, 24 Dec 2025 08:50:43 +0700 Subject: [PATCH] v7.0 --- app.go | 112 ++- backend/amazon.go | 20 +- backend/cover.go | 26 +- backend/filemanager.go | 8 +- backend/filename.go | 20 +- backend/lyrics.go | 26 +- backend/qobuz.go | 22 +- backend/spotify_metadata.go | 569 +++++++++----- backend/tidal.go | 82 +- frontend/index.html | 2 +- frontend/package.json | 4 +- frontend/package.json.md5 | 2 +- frontend/pnpm-lock.yaml | 330 ++++---- frontend/src/App.tsx | 95 ++- frontend/src/components/AlbumInfo.tsx | 4 +- frontend/src/components/ArtistInfo.tsx | 4 +- frontend/src/components/FileManagerPage.tsx | 821 +++++++++++--------- frontend/src/components/PlaylistInfo.tsx | 4 +- frontend/src/components/SearchBar.tsx | 521 ++++++++++++- frontend/src/components/SettingsPage.tsx | 2 + frontend/src/components/TrackInfo.tsx | 79 +- frontend/src/components/TrackList.tsx | 8 +- frontend/src/hooks/useCover.ts | 21 +- frontend/src/hooks/useLyrics.ts | 28 +- frontend/src/index.css | 2 +- frontend/src/lib/settings.ts | 2 +- frontend/src/types/api.ts | 8 + tidal.json | 10 - wails.json | 2 +- 29 files changed, 1902 insertions(+), 932 deletions(-) delete mode 100644 tidal.json 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)} - @@ -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)} + + + + + Preview + + + + + + 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)} + + + + + Preview + + + + + + 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" />
- {/* Rename Format */} -
-
- - - - - - -

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}

-
-
-
-
- - {formatPreset === "custom" && ( - setCustomFormat(e.target.value)} - placeholder="{artist} - {title}" - className="flex-1" - /> - )} - - - - - 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 */} +
+ + +
+ {/* Rename Format - Only for Track tab */} + {activeTab === "track" && ( +
+
+ + + + + + +

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}

+
+
+
+
+ + {formatPreset === "custom" && ( + setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1" /> + )} + + + + + 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 */}
-
-
- - - {selectedFiles.size} of {allAudioFiles.length} file(s) selected - + {activeTab === "track" && ( +
+
+ + {selectedFiles.size} of {allAudioFiles.length} file(s) selected +
+
+ + +
-
- - -
-
+ )}
{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. @@ -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 ? ( - + ) : ( <> - + )} @@ -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
)} - - - - +
@@ -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? - + + + + + + {/* Lyrics Preview Dialog */} + + + + Lyrics Preview + {lyricsFile.split(/[/\\]/).pop()} + +
+ + +
+
+
+              {lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent) || "No lyrics content"}
+            
+
+ + + + +
+
+ + {/* Cover Preview Dialog */} + + + + Cover Preview + {coverFile.split(/[/\\]/).pop()} + +
+ {coverData ? Cover :
Loading...
} +
+ +
+
+ + {/* Manual Rename Dialog */} + + + + Rename File + {manualRenameFile.split(/[/\\]/).pop()} + +
+ +
+ setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { if (e.key === "Enter" && !manualRenaming) handleConfirmManualRename(); }} /> + {manualRenameFile.match(/\.[^.]+$/)?.[0] || ""} +
+
+ + +
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 ( -
+
- + {/* Mode Toggle */} +
+ + +
+ -

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 && ( - - )} -
- + )} ) : ( <> - - Fetch + setSearchQuery(e.target.value)} + className="pr-8" + /> + {searchQuery && ( + + )} )} - +
+ + {!searchMode && ( + + )}
- {!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} + +
+ ))} +
+
+ )} + + {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 ( + + ); + })} +
+ + {/* Tab Content */} +
+ {/* Tracks */} + {activeTab === "tracks" && searchResults?.tracks.map((track) => ( + + ))} + + {/* Albums */} + {activeTab === "albums" && searchResults?.albums.map((album) => ( + + ))} + + {/* Artists */} + {activeTab === "artists" && searchResults?.artists.map((artist) => ( + + ))} + + {/* Playlists */} + {activeTab === "playlists" && searchResults?.playlists.map((playlist) => ( + + ))} +
+ + {/* Load More Button */} + {hasMore[activeTab] && ( +
+ +
+ )} + + )} +
+ )}
); } 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 && ( - <> - {track.name} - {isHoveringCover && onDownloadCover && ( -
- - - - - -

Download Cover

-
-
-
- )} - + {track.name} )}
@@ -136,7 +113,7 @@ export function TrackInfo({ + + +

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({