This commit is contained in:
afkarxyz
2025-12-24 08:50:43 +07:00
parent cb2a41d068
commit c150124273
29 changed files with 1902 additions and 932 deletions
+111 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -124,6 +125,56 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
return string(jsonData), nil return string(jsonData), nil
} }
// SpotifySearchRequest represents the request structure for searching Spotify
type SpotifySearchRequest struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
// SearchSpotify searches for tracks, albums, artists, and playlists on Spotify
func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, error) {
if req.Query == "" {
return nil, fmt.Errorf("search query is required")
}
if req.Limit <= 0 {
req.Limit = 10
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return backend.SearchSpotify(ctx, req.Query, req.Limit)
}
// SpotifySearchByTypeRequest represents the request for searching by specific type with offset
type SpotifySearchByTypeRequest struct {
Query string `json:"query"`
SearchType string `json:"search_type"` // track, album, artist, playlist
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// SearchSpotifyByType searches for a specific type with offset support for pagination
func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.SearchResult, error) {
if req.Query == "" {
return nil, fmt.Errorf("search query is required")
}
if req.SearchType == "" {
return nil, fmt.Errorf("search type is required")
}
if req.Limit <= 0 {
req.Limit = 50
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return backend.SearchSpotifyByType(ctx, req.Query, req.SearchType, req.Limit, req.Offset)
}
// DownloadTrack downloads a track by ISRC // DownloadTrack downloads a track by ISRC
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.ISRC == "" { if req.ISRC == "" {
@@ -185,7 +236,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
// Fallback: if we have track metadata, check if file already exists by filename // Fallback: if we have track metadata, check if file already exists by filename
if req.TrackName != "" && req.ArtistName != "" { if req.TrackName != "" && req.ArtistName != "" {
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber) expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename) expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
@@ -502,11 +553,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"` UseAlbumTrackNumber bool `json:"use_album_track_number"`
DiscNumber int `json:"disc_number"`
} }
// DownloadLyrics downloads lyrics for a single track // DownloadLyrics downloads lyrics for a single track
@@ -523,11 +578,15 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
SpotifyID: req.SpotifyID, SpotifyID: req.SpotifyID,
TrackName: req.TrackName, TrackName: req.TrackName,
ArtistName: req.ArtistName, ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir, OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat, FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
Position: req.Position, Position: req.Position,
UseAlbumTrackNumber: req.UseAlbumTrackNumber, UseAlbumTrackNumber: req.UseAlbumTrackNumber,
DiscNumber: req.DiscNumber,
} }
resp, err := client.DownloadLyrics(backendReq) resp, err := client.DownloadLyrics(backendReq)
@@ -546,10 +605,14 @@ type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
DiscNumber int `json:"disc_number"`
} }
// DownloadCover downloads cover art for a single track // DownloadCover downloads cover art for a single track
@@ -566,10 +629,14 @@ func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResp
CoverURL: req.CoverURL, CoverURL: req.CoverURL,
TrackName: req.TrackName, TrackName: req.TrackName,
ArtistName: req.ArtistName, ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ReleaseDate: req.ReleaseDate,
OutputDir: req.OutputDir, OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat, FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
Position: req.Position, Position: req.Position,
DiscNumber: req.DiscNumber,
} }
resp, err := client.DownloadCover(backendReq) resp, err := client.DownloadCover(backendReq)
@@ -713,6 +780,49 @@ func (a *App) RenameFilesByMetadata(files []string, format string) []backend.Ren
return backend.RenameFiles(files, format) 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 // CheckFileExistenceRequest represents a track to check for existence
type CheckFileExistenceRequest struct { type CheckFileExistenceRequest struct {
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
+19 -1
View File
@@ -382,7 +382,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download) // Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, false) expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename) expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
@@ -403,6 +403,14 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
if spotifyTrackName != "" && spotifyArtistName != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName) safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName) safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Extract year from release date
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
// Build filename based on format settings // Build filename based on format settings
var newFilename string var newFilename string
@@ -412,6 +420,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = filenameFormat newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle) newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist) newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
// Handle disc number
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
} else {
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
+24 -2
View File
@@ -22,10 +22,14 @@ type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
DiscNumber int `json:"disc_number"`
} }
// CoverDownloadResponse represents the response from cover download // CoverDownloadResponse represents the response from cover download
@@ -50,9 +54,17 @@ func NewCoverClient() *CoverClient {
} }
// buildCoverFilename builds the cover filename based on settings (same as track filename) // buildCoverFilename builds the cover filename based on settings (same as track filename)
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string { func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string var filename string
@@ -61,6 +73,16 @@ func buildCoverFilename(trackName, artistName, filenameFormat string, includeTra
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
@@ -176,7 +198,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
if filenameFormat == "" { if filenameFormat == "" {
filenameFormat = "title-artist" // default filenameFormat = "title-artist" // default
} }
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position) filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename) filePath := filepath.Join(outputDir, filename)
// Check if file already exists // Check if file already exists
+7 -1
View File
@@ -341,12 +341,18 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
result := format result := format
// Extract year (first 4 characters only)
year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
// Replace placeholders // Replace placeholders
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title)) result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist)) result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album)) result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) 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 // Track number with padding
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
+19 -1
View File
@@ -10,10 +10,18 @@ import (
) )
// BuildExpectedFilename builds the expected filename based on track metadata and settings // BuildExpectedFilename builds the expected filename based on track metadata and settings
func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name // Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string var filename string
@@ -22,6 +30,16 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
+24 -2
View File
@@ -46,11 +46,15 @@ type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"` UseAlbumTrackNumber bool `json:"use_album_track_number"`
DiscNumber int `json:"disc_number"`
} }
// LyricsDownloadResponse represents the response from lyrics download // LyricsDownloadResponse represents the response from lyrics download
@@ -308,9 +312,17 @@ func msToLRCTimestamp(msStr string) string {
} }
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename) // buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string { func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string var filename string
@@ -319,6 +331,16 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators // Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 { if position > 0 {
@@ -378,7 +400,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
if filenameFormat == "" { if filenameFormat == "" {
filenameFormat = "title-artist" // default filenameFormat = "title-artist" // default
} }
filename := buildLyricsFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position) filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename) filePath := filepath.Join(outputDir, filename)
// Check if file already exists // Check if file already exists
+20 -2
View File
@@ -263,7 +263,7 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err return err
} }
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string var filename string
// Determine track number to use // Determine track number to use
@@ -272,11 +272,27 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
numberToUse = trackNumber numberToUse = trackNumber
} }
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
// Check if format is a template (contains {}) // Check if format is a template (contains {})
if strings.Contains(format, "{") { if strings.Contains(format, "{") {
filename = format filename = format
filename = strings.ReplaceAll(filename, "{title}", title) filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist) filename = strings.ReplaceAll(filename, "{artist}", artist)
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators // Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 { if numberToUse > 0 {
@@ -355,6 +371,8 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists) safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle) safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC) // Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists { if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
@@ -363,7 +381,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
} }
// Build filename based on format settings (use Spotify track number) // Build filename based on format settings (use Spotify track number)
filename := buildQobuzFilename(safeTitle, safeArtist, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename) filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
+359 -210
View File
@@ -2,11 +2,7 @@ package backend
import ( import (
"context" "context"
"crypto/hmac" "encoding/base64"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -14,8 +10,6 @@ import (
"math/rand" "math/rand"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -23,13 +17,12 @@ import (
) )
const ( const (
spotifyTokenURL = "https://open.spotify.com/api/token" spotifyTokenURL = "https://accounts.spotify.com/api/token"
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
albumBaseURL = "https://api.spotify.com/v1/albums/%s" albumBaseURL = "https://api.spotify.com/v1/albums/%s"
trackBaseURL = "https://api.spotify.com/v1/tracks/%s" trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s" artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums" artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
secretBytesRemotePath = "https://cdn.jsdelivr.net/gh/afkarxyz/secretBytes@refs/heads/main/secrets/secretBytes.json"
) )
var ( var (
@@ -38,18 +31,37 @@ var (
// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API. // SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API.
type SpotifyMetadataClient struct { type SpotifyMetadataClient struct {
httpClient *http.Client httpClient *http.Client
rng *rand.Rand clientID string
rngMu sync.Mutex clientSecret string
userAgent string cachedToken string
tokenExpiresAt time.Time
rng *rand.Rand
rngMu sync.Mutex
userAgent string
} }
// NewSpotifyMetadataClient creates a ready-to-use client with sane defaults. // NewSpotifyMetadataClient creates a ready-to-use client with Official Spotify API credentials.
func NewSpotifyMetadataClient() *SpotifyMetadataClient { func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano()) src := rand.NewSource(time.Now().UnixNano())
// Decode client ID from base64
clientID := ""
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
clientID = string(decoded)
}
// Decode client secret from base64
clientSecret := ""
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
clientSecret = string(decoded)
}
c := &SpotifyMetadataClient{ c := &SpotifyMetadataClient{
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
rng: rand.New(src), clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
} }
c.userAgent = c.randomUserAgent() c.userAgent = c.randomUserAgent()
return c return c
@@ -187,17 +199,10 @@ type spotifyURI struct {
DiscographyGroup string DiscographyGroup string
} }
type secretEntry struct {
Version int `json:"version"`
Secret []int `json:"secret"`
}
type serverTimeResponse struct {
ServerTime int64 `json:"serverTime"`
}
type accessTokenResponse struct { type accessTokenResponse struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"access_token"`
ExpiresIn interface{} `json:"expires_in"` // Can be number or string
TokenType string `json:"token_type"`
} }
type image struct { type image struct {
@@ -352,7 +357,9 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
case "artist_discography": case "artist_discography":
return c.fetchArtistDiscography(ctx, parsed, token, batch, delay) return c.fetchArtistDiscography(ctx, parsed, token, batch, delay)
case "artist": case "artist":
return c.fetchArtist(ctx, parsed.ID, token) // Automatically fetch discography for artist URLs to get full data (albums + tracks)
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
return c.fetchArtistDiscography(ctx, discographyParsed, token, batch, delay)
default: default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
} }
@@ -859,211 +866,58 @@ func (c *SpotifyMetadataClient) randRange(min, max int) int {
} }
func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) { func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) {
code, serverTime, version, err := c.generateTOTP(ctx) // Return cached token if still valid
if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) {
return c.cachedToken, nil
}
// Prepare request body for Client Credentials Flow
data := url.Values{}
data.Set("grant_type", "client_credentials")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode()))
if err != nil { if err != nil {
return "", err return "", err
} }
timestampMS := time.Now().UnixMilli() // Set Basic Auth header
params := url.Values{} req.SetBasicAuth(c.clientID, c.clientSecret)
params.Set("reason", "init") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
params.Set("productType", "web-player")
params.Set("totp", code)
params.Set("totpServerTime", strconv.FormatInt(serverTime, 10))
params.Set("totpVer", strconv.Itoa(version))
params.Set("sTime", strconv.FormatInt(serverTime, 10))
params.Set("cTime", strconv.FormatInt(timestampMS, 10))
params.Set("buildVer", "web-player_2025-07-02_1720000000000_12345678")
params.Set("buildDate", "2025-07-02")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotifyTokenURL, nil)
if err != nil {
return "", err
}
req.URL.RawQuery = params.Encode()
req.Header = c.baseHeaders()
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil { if err != nil {
return "", err return "", err
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get access token. Status code: %d", resp.StatusCode) return "", fmt.Errorf("failed to get access token. Status code: %d, Response: %s", resp.StatusCode, string(body))
} }
var token accessTokenResponse var token accessTokenResponse
if err := json.Unmarshal(body, &token); err != nil { if err := json.Unmarshal(body, &token); err != nil {
return "", err return "", err
} }
if token.AccessToken == "" { if token.AccessToken == "" {
return "", errors.New("failed to get access token: empty token received") return "", errors.New("failed to get access token: empty token received")
} }
// Cache the token
c.cachedToken = token.AccessToken
// Official API returns expires_in in seconds
if expiresIn, ok := token.ExpiresIn.(float64); ok {
c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) // Refresh 60 seconds before expiry
}
return token.AccessToken, nil return token.AccessToken, nil
} }
func (c *SpotifyMetadataClient) generateTOTP(ctx context.Context) (string, int64, int, error) {
secrets, _, err := c.fetchSecretBytes(ctx)
if err != nil {
return "", 0, 0, err
}
if len(secrets) == 0 {
return "", 0, 0, errors.New("no secrets available")
}
latest := secrets[0]
for _, entry := range secrets[1:] {
if entry.Version > latest.Version {
latest = entry
}
}
builder := strings.Builder{}
for idx, val := range latest.Secret {
processed := val ^ ((idx % 33) + 9)
builder.WriteString(strconv.Itoa(processed))
}
utfBytes := []byte(builder.String())
hexStr := hex.EncodeToString(utfBytes)
secretBytes, err := hex.DecodeString(hexStr)
if err != nil {
return "", 0, 0, err
}
b32Secret := base32.StdEncoding.EncodeToString(secretBytes)
serverTime, err := c.fetchServerTime(ctx)
if err != nil {
return "", 0, 0, err
}
code, err := computeTOTP(b32Secret, serverTime)
if err != nil {
return "", 0, 0, err
}
return code, serverTime, latest.Version, nil
}
func (c *SpotifyMetadataClient) fetchSecretBytes(ctx context.Context) ([]secretEntry, bool, error) {
// Add cache busting parameter with current timestamp
urlWithCacheBust := fmt.Sprintf("%s?t=%d", secretBytesRemotePath, time.Now().Unix())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlWithCacheBust, nil)
if err == nil {
// Add headers to bypass cache
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Expires", "0")
resp, err := c.httpClient.Do(req)
if err == nil {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr == nil && resp.StatusCode == http.StatusOK {
var secrets []secretEntry
if jsonErr := json.Unmarshal(body, &secrets); jsonErr == nil {
return secrets, false, nil
}
}
}
}
home, err := os.UserHomeDir()
if err != nil {
return nil, false, fmt.Errorf("GitHub fetch failed and could not resolve home directory: %w", err)
}
localPath := filepath.Join(home, ".spotify-secret", "secretBytes.json")
data, err := os.ReadFile(localPath)
if err != nil {
return nil, false, fmt.Errorf("failed to fetch secrets from both GitHub and local: %w", err)
}
var secrets []secretEntry
if err := json.Unmarshal(data, &secrets); err != nil {
return nil, false, fmt.Errorf("failed to process local secrets: %w", err)
}
return secrets, true, nil
}
func (c *SpotifyMetadataClient) fetchServerTime(ctx context.Context) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.spotify.com/api/server-time", nil)
if err != nil {
return 0, err
}
req.Header = c.serverTimeHeaders()
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return 0, err
}
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to get server time. Status code: %d", resp.StatusCode)
}
var payload serverTimeResponse
if err := json.Unmarshal(body, &payload); err != nil {
return 0, err
}
if payload.ServerTime == 0 {
return 0, errors.New("failed to fetch server time from Spotify")
}
return payload.ServerTime, nil
}
func (c *SpotifyMetadataClient) serverTimeHeaders() http.Header {
h := http.Header{}
h.Set("Host", "open.spotify.com")
h.Set("User-Agent", c.randomUserAgent())
h.Set("Accept", "*/*")
return h
}
func computeTOTP(b32Secret string, timestamp int64) (string, error) {
normalized := strings.ToUpper(strings.ReplaceAll(b32Secret, " ", ""))
key, err := base32.StdEncoding.DecodeString(normalized)
if err != nil {
return "", err
}
// Normalise milliseconds if necessary.
if timestamp > 1_000_000_000_000 {
timestamp /= 1000
}
counter := uint64(timestamp / 30)
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, key)
if _, err := mac.Write(buf[:]); err != nil {
return "", err
}
sum := mac.Sum(nil)
if len(sum) < 20 {
return "", errors.New("unexpected hmac length for TOTP")
}
offset := sum[len(sum)-1] & 0x0f
binaryCode := (int(sum[offset])&0x7f)<<24 |
(int(sum[offset+1])&0xff)<<16 |
(int(sum[offset+2])&0xff)<<8 |
(int(sum[offset+3]) & 0xff)
otp := binaryCode % 1_000_000
return fmt.Sprintf("%06d", otp), nil
}
func parseSpotifyURI(input string) (spotifyURI, error) { func parseSpotifyURI(input string) (spotifyURI, error) {
trimmed := strings.TrimSpace(input) trimmed := strings.TrimSpace(input)
if trimmed == "" { if trimmed == "" {
@@ -1239,3 +1093,298 @@ func maxInt(a, b int) int {
} }
return b return b
} }
// SearchResult represents a single search result item
type SearchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // track, album, artist, playlist
Artists string `json:"artists,omitempty"`
AlbumName string `json:"album_name,omitempty"`
Images string `json:"images"`
ReleaseDate string `json:"release_date,omitempty"`
ExternalURL string `json:"external_urls"`
Duration int `json:"duration_ms,omitempty"`
TotalTracks int `json:"total_tracks,omitempty"`
Owner string `json:"owner,omitempty"` // for playlists
}
// SearchResponse contains search results grouped by type
type SearchResponse struct {
Tracks []SearchResult `json:"tracks"`
Albums []SearchResult `json:"albums"`
Artists []SearchResult `json:"artists"`
Playlists []SearchResult `json:"playlists"`
}
// Spotify API search response structures
type searchTracksResponse struct {
Tracks struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration_ms"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
Album struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
ReleaseDate string `json:"release_date"`
ExternalURL externalURL `json:"external_urls"`
} `json:"album"`
} `json:"items"`
} `json:"tracks"`
}
type searchAlbumsResponse struct {
Albums struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
AlbumType string `json:"album_type"`
TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"`
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Artists []artist `json:"artists"`
} `json:"items"`
} `json:"albums"`
}
type searchArtistsResponse struct {
Artists struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Followers struct {
Total int `json:"total"`
} `json:"followers"`
} `json:"items"`
} `json:"artists"`
}
type searchPlaylistsResponse struct {
Playlists struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
ExternalURL externalURL `json:"external_urls"`
Owner struct {
DisplayName string `json:"display_name"`
} `json:"owner"`
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
} `json:"items"`
} `json:"playlists"`
}
// Search performs a search on Spotify and returns results for tracks, albums, artists, and playlists
func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) {
if query == "" {
return nil, errors.New("search query cannot be empty")
}
if limit <= 0 || limit > 50 {
limit = 50
}
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
// URL encode the query
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=track,album,artist,playlist&limit=%d", encodedQuery, limit)
response := &SearchResponse{
Tracks: make([]SearchResult, 0),
Albums: make([]SearchResult, 0),
Artists: make([]SearchResult, 0),
Playlists: make([]SearchResult, 0),
}
// Fetch tracks
var tracksResp searchTracksResponse
if err := c.getJSON(ctx, searchURL, token, &tracksResp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range tracksResp.Tracks.Items {
response.Tracks = append(response.Tracks, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "track",
Artists: joinArtists(item.Artists),
AlbumName: item.Album.Name,
Images: firstImageURL(item.Album.Images),
ReleaseDate: item.Album.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
Duration: item.DurationMS,
})
}
// Fetch albums
var albumsResp searchAlbumsResponse
if err := c.getJSON(ctx, searchURL, token, &albumsResp); err == nil {
for _, item := range albumsResp.Albums.Items {
response.Albums = append(response.Albums, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "album",
Artists: joinArtists(item.Artists),
Images: firstImageURL(item.Images),
ReleaseDate: item.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
TotalTracks: item.TotalTracks,
})
}
}
// Fetch artists
var artistsResp searchArtistsResponse
if err := c.getJSON(ctx, searchURL, token, &artistsResp); err == nil {
for _, item := range artistsResp.Artists.Items {
response.Artists = append(response.Artists, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "artist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
})
}
}
// Fetch playlists
var playlistsResp searchPlaylistsResponse
if err := c.getJSON(ctx, searchURL, token, &playlistsResp); err == nil {
for _, item := range playlistsResp.Playlists.Items {
response.Playlists = append(response.Playlists, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "playlist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
Owner: item.Owner.DisplayName,
TotalTracks: item.Tracks.Total,
})
}
}
return response, nil
}
// SearchSpotify is a convenience wrapper for the Search method
func SearchSpotify(ctx context.Context, query string, limit int) (*SearchResponse, error) {
client := NewSpotifyMetadataClient()
return client.Search(ctx, query, limit)
}
// SearchByType searches for a specific type (track, album, artist, playlist) with offset support
func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
if query == "" {
return nil, errors.New("search query cannot be empty")
}
if limit <= 0 || limit > 50 {
limit = 50
}
if offset < 0 || offset > 1000 {
offset = 0
}
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=%s&limit=%d&offset=%d", encodedQuery, searchType, limit, offset)
results := make([]SearchResult, 0)
switch searchType {
case "track":
var resp searchTracksResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Tracks.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "track",
Artists: joinArtists(item.Artists),
AlbumName: item.Album.Name,
Images: firstImageURL(item.Album.Images),
ReleaseDate: item.Album.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
Duration: item.DurationMS,
})
}
case "album":
var resp searchAlbumsResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Albums.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "album",
Artists: joinArtists(item.Artists),
Images: firstImageURL(item.Images),
ReleaseDate: item.ReleaseDate,
ExternalURL: item.ExternalURL.Spotify,
TotalTracks: item.TotalTracks,
})
}
case "artist":
var resp searchArtistsResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Artists.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "artist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
})
}
case "playlist":
var resp searchPlaylistsResponse
if err := c.getJSON(ctx, searchURL, token, &resp); err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
for _, item := range resp.Playlists.Items {
results = append(results, SearchResult{
ID: item.ID,
Name: item.Name,
Type: "playlist",
Images: firstImageURL(item.Images),
ExternalURL: item.ExternalURL.Spotify,
Owner: item.Owner.DisplayName,
TotalTracks: item.Tracks.Total,
})
}
default:
return nil, fmt.Errorf("invalid search type: %s", searchType)
}
return results, nil
}
// SearchSpotifyByType is a convenience wrapper for SearchByType
func SearchSpotifyByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) {
client := NewSpotifyMetadataClient()
return client.SearchByType(ctx, query, searchType, limit, offset)
}
+45 -37
View File
@@ -128,41 +128,25 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
} }
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
// Decode base64 API URL // Hardcoded API URLs (base64 encoded for obfuscation)
apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==") encodedAPIs := []string{
"dm9nZWwucXFkbC5zaXRl", // API 1
// Add cache-busting parameter with current timestamp "bWF1cy5xcWRsLnNpdGU=", // API 2
urlWithCacheBust := fmt.Sprintf("%s?t=%d", string(apiURL), time.Now().Unix()) "aHVuZC5xcWRsLnNpdGU=", // API 3
"a2F0emUucXFkbC5zaXRl", // API 4
// Create request with cache bypass headers "d29sZi5xcWRsLnNpdGU=", // API 5
req, err := http.NewRequest("GET", urlWithCacheBust, nil) "dGlkYWwua2lub3BsdXMub25saW5l", // API 6
if err != nil { "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7
return nil, fmt.Errorf("failed to create request: %w", err) "dHJpdG9uLnNxdWlkLnd0Zg==", // API 8
}
// Add headers to bypass cache
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Expires", "0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch API list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to fetch API list: HTTP %d", resp.StatusCode)
}
var apiList []string
if err := json.NewDecoder(resp.Body).Decode(&apiList); err != nil {
return nil, fmt.Errorf("failed to decode API list: %w", err)
} }
var apis []string var apis []string
for _, api := range apiList { for _, encoded := range encodedAPIs {
apis = append(apis, "https://"+api) decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
continue
}
apis = append(apis, "https://"+string(decoded))
} }
return apis, nil return apis, nil
@@ -834,6 +818,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
artistNameForFile := sanitizeFilename(artistName) artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle) trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists // Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
@@ -842,7 +828,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
} }
// Build filename based on format settings (use sanitized versions for filename) // Build filename based on format settings (use sanitized versions for filename)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -947,6 +933,8 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
artistNameForFile := sanitizeFilename(artistName) artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle) trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists // Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
@@ -954,7 +942,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "EXISTS:" + existingFile, nil return "EXISTS:" + existingFile, nil
} }
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -1081,6 +1069,8 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
finalArtistNameForFile := sanitizeFilename(finalArtistName) finalArtistNameForFile := sanitizeFilename(finalArtistName)
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
finalAlbumArtistForFile := sanitizeFilename(albumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC) // Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
@@ -1089,7 +1079,7 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
} }
// Build filename // Build filename
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, finalAlbumTitleForFile, finalAlbumArtistForFile, releaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -1405,6 +1395,8 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
// Sanitize for filename only (not for metadata) // Sanitize for filename only (not for metadata)
finalArtistNameForFile := sanitizeFilename(finalArtistName) finalArtistNameForFile := sanitizeFilename(finalArtistName)
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle)
finalAlbumArtistForFile := sanitizeFilename(albumArtist)
// Check if file already exists (use Spotify ISRC) // Check if file already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists { if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
@@ -1412,7 +1404,7 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
return "EXISTS:" + existingFile, nil return "EXISTS:" + existingFile, nil
} }
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, finalAlbumTitleForFile, finalAlbumArtistForFile, releaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
outputFilename := filepath.Join(outputDir, filename) outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
@@ -1511,7 +1503,7 @@ func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISR
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC)
} }
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string var filename string
// Determine track number to use // Determine track number to use
@@ -1520,11 +1512,27 @@ func buildTidalFilename(title, artist string, trackNumber int, format string, in
numberToUse = trackNumber numberToUse = trackNumber
} }
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
// Check if format is a template (contains {}) // Check if format is a template (contains {})
if strings.Contains(format, "{") { if strings.Contains(format, "{") {
filename = format filename = format
filename = strings.ReplaceAll(filename, "{title}", title) filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist) filename = strings.ReplaceAll(filename, "{artist}", artist)
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators // Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 { if numberToUse > 0 {
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<title>SpotiFLAC</title> <title>SpotiFLAC</title>
</head> </head>
<body> <body>
+2 -2
View File
@@ -27,7 +27,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.12.1", "motion": "^12.23.26",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -48,7 +48,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.50.0", "typescript-eslint": "^8.50.1",
"vite": "^7.3.0" "vite": "^7.3.0"
} }
} }
+1 -1
View File
@@ -1 +1 @@
c94dda3302d3338d7909ef5d634d0fde 0f9764c2a4597a75120d3e76c32af7a9
+165 -165
View File
@@ -54,7 +54,7 @@ importers:
specifier: ^0.562.0 specifier: ^0.562.0
version: 0.562.0(react@19.2.3) version: 0.562.0(react@19.2.3)
motion: 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) version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
@@ -112,8 +112,8 @@ importers:
specifier: ~5.9.3 specifier: ~5.9.3
version: 5.9.3 version: 5.9.3
typescript-eslint: typescript-eslint:
specifier: ^8.50.0 specifier: ^8.50.1
version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
vite: vite:
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) 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': '@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
'@rollup/rollup-android-arm-eabi@4.53.5': '@rollup/rollup-android-arm-eabi@4.54.0':
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@rollup/rollup-android-arm64@4.53.5': '@rollup/rollup-android-arm64@4.54.0':
resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rollup/rollup-darwin-arm64@4.53.5': '@rollup/rollup-darwin-arm64@4.54.0':
resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rollup/rollup-darwin-x64@4.53.5': '@rollup/rollup-darwin-x64@4.54.0':
resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rollup/rollup-freebsd-arm64@4.53.5': '@rollup/rollup-freebsd-arm64@4.54.0':
resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-freebsd-x64@4.53.5': '@rollup/rollup-freebsd-x64@4.54.0':
resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.53.5': '@rollup/rollup-linux-arm-gnueabihf@4.54.0':
resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.53.5': '@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.53.5': '@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-musl@4.53.5': '@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.53.5': '@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.53.5': '@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.53.5': '@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.53.5': '@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.53.5': '@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-gnu@4.53.5': '@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-musl@4.53.5': '@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-openharmony-arm64@4.53.5': '@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.53.5': '@rollup/rollup-win32-arm64-msvc@4.54.0':
resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.53.5': '@rollup/rollup-win32-ia32-msvc@4.54.0':
resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-gnu@4.53.5': '@rollup/rollup-win32-x64-gnu@4.54.0':
resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-msvc@4.53.5': '@rollup/rollup-win32-x64-msvc@4.54.0':
resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1255,63 +1255,63 @@ packages:
'@types/react@19.2.7': '@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@typescript-eslint/eslint-plugin@8.50.0': '@typescript-eslint/eslint-plugin@8.50.1':
resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==} resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.50.0 '@typescript-eslint/parser': ^8.50.1
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.50.0': '@typescript-eslint/parser@8.50.1':
resolution: {integrity: sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==} resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.50.0': '@typescript-eslint/project-service@8.50.1':
resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==} resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.50.0': '@typescript-eslint/scope-manager@8.50.1':
resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.50.0': '@typescript-eslint/tsconfig-utils@8.50.1':
resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==} resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.50.0': '@typescript-eslint/type-utils@8.50.1':
resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==} resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.50.0': '@typescript-eslint/types@8.50.1':
resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==} resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.50.0': '@typescript-eslint/typescript-estree@8.50.1':
resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==} resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.50.0': '@typescript-eslint/utils@8.50.1':
resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==} resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.50.0': '@typescript-eslint/visitor-keys@8.50.1':
resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@5.1.2': '@vitejs/plugin-react@5.1.2':
@@ -1347,8 +1347,8 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
baseline-browser-mapping@2.9.10: baseline-browser-mapping@2.9.11:
resolution: {integrity: sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==} resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
hasBin: true hasBin: true
brace-expansion@1.1.12: brace-expansion@1.1.12:
@@ -1366,8 +1366,8 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
caniuse-lite@1.0.30001760: caniuse-lite@1.0.30001761:
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -1866,8 +1866,8 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
rollup@4.53.5: rollup@4.54.0:
resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
@@ -1943,8 +1943,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
typescript-eslint@8.50.0: typescript-eslint@8.50.1:
resolution: {integrity: sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==} resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
@@ -2865,70 +2865,70 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.53': {} '@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 optional: true
'@rollup/rollup-android-arm64@4.53.5': '@rollup/rollup-android-arm64@4.54.0':
optional: true optional: true
'@rollup/rollup-darwin-arm64@4.53.5': '@rollup/rollup-darwin-arm64@4.54.0':
optional: true optional: true
'@rollup/rollup-darwin-x64@4.53.5': '@rollup/rollup-darwin-x64@4.54.0':
optional: true optional: true
'@rollup/rollup-freebsd-arm64@4.53.5': '@rollup/rollup-freebsd-arm64@4.54.0':
optional: true optional: true
'@rollup/rollup-freebsd-x64@4.53.5': '@rollup/rollup-freebsd-x64@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.53.5': '@rollup/rollup-linux-arm-gnueabihf@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-arm-musleabihf@4.53.5': '@rollup/rollup-linux-arm-musleabihf@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-arm64-gnu@4.53.5': '@rollup/rollup-linux-arm64-gnu@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-arm64-musl@4.53.5': '@rollup/rollup-linux-arm64-musl@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-loong64-gnu@4.53.5': '@rollup/rollup-linux-loong64-gnu@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-ppc64-gnu@4.53.5': '@rollup/rollup-linux-ppc64-gnu@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-riscv64-gnu@4.53.5': '@rollup/rollup-linux-riscv64-gnu@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-riscv64-musl@4.53.5': '@rollup/rollup-linux-riscv64-musl@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-s390x-gnu@4.53.5': '@rollup/rollup-linux-s390x-gnu@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-x64-gnu@4.53.5': '@rollup/rollup-linux-x64-gnu@4.54.0':
optional: true optional: true
'@rollup/rollup-linux-x64-musl@4.53.5': '@rollup/rollup-linux-x64-musl@4.54.0':
optional: true optional: true
'@rollup/rollup-openharmony-arm64@4.53.5': '@rollup/rollup-openharmony-arm64@4.54.0':
optional: true optional: true
'@rollup/rollup-win32-arm64-msvc@4.53.5': '@rollup/rollup-win32-arm64-msvc@4.54.0':
optional: true optional: true
'@rollup/rollup-win32-ia32-msvc@4.53.5': '@rollup/rollup-win32-ia32-msvc@4.54.0':
optional: true optional: true
'@rollup/rollup-win32-x64-gnu@4.53.5': '@rollup/rollup-win32-x64-gnu@4.54.0':
optional: true optional: true
'@rollup/rollup-win32-x64-msvc@4.53.5': '@rollup/rollup-win32-x64-msvc@4.54.0':
optional: true optional: true
'@tailwindcss/node@4.1.18': '@tailwindcss/node@4.1.18':
@@ -3036,14 +3036,14 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 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: dependencies:
'@eslint-community/regexpp': 4.12.2 '@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/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.50.0 '@typescript-eslint/scope-manager': 8.50.1
'@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)
'@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)
'@typescript-eslint/visitor-keys': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.1
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
@@ -3052,41 +3052,41 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@typescript-eslint/scope-manager': 8.50.0 '@typescript-eslint/scope-manager': 8.50.1
'@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)
'@typescript-eslint/visitor-keys': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.1
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
'@typescript-eslint/types': 8.50.0 '@typescript-eslint/types': 8.50.1
debug: 4.4.3 debug: 4.4.3
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.50.0': '@typescript-eslint/scope-manager@8.50.1':
dependencies: dependencies:
'@typescript-eslint/types': 8.50.0 '@typescript-eslint/types': 8.50.1
'@typescript-eslint/visitor-keys': 8.50.0 '@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: dependencies:
typescript: 5.9.3 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: dependencies:
'@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)
'@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)
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
ts-api-utils: 2.1.0(typescript@5.9.3) ts-api-utils: 2.1.0(typescript@5.9.3)
@@ -3094,14 +3094,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@typescript-eslint/project-service': 8.50.0(typescript@5.9.3) '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
'@typescript-eslint/types': 8.50.0 '@typescript-eslint/types': 8.50.1
'@typescript-eslint/visitor-keys': 8.50.0 '@typescript-eslint/visitor-keys': 8.50.1
debug: 4.4.3 debug: 4.4.3
minimatch: 9.0.5 minimatch: 9.0.5
semver: 7.7.3 semver: 7.7.3
@@ -3111,20 +3111,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.50.0 '@typescript-eslint/scope-manager': 8.50.1
'@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)
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/visitor-keys@8.50.0': '@typescript-eslint/visitor-keys@8.50.1':
dependencies: dependencies:
'@typescript-eslint/types': 8.50.0 '@typescript-eslint/types': 8.50.1
eslint-visitor-keys: 4.2.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))': '@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: {} balanced-match@1.0.2: {}
baseline-browser-mapping@2.9.10: {} baseline-browser-mapping@2.9.11: {}
brace-expansion@1.1.12: brace-expansion@1.1.12:
dependencies: dependencies:
@@ -3177,15 +3177,15 @@ snapshots:
browserslist@4.28.1: browserslist@4.28.1:
dependencies: dependencies:
baseline-browser-mapping: 2.9.10 baseline-browser-mapping: 2.9.11
caniuse-lite: 1.0.30001760 caniuse-lite: 1.0.30001761
electron-to-chromium: 1.5.267 electron-to-chromium: 1.5.267
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1) update-browserslist-db: 1.2.3(browserslist@4.28.1)
callsites@3.1.0: {} callsites@3.1.0: {}
caniuse-lite@1.0.30001760: {} caniuse-lite@1.0.30001761: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
@@ -3634,32 +3634,32 @@ snapshots:
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
rollup@4.53.5: rollup@4.54.0:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
optionalDependencies: optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.53.5 '@rollup/rollup-android-arm-eabi': 4.54.0
'@rollup/rollup-android-arm64': 4.53.5 '@rollup/rollup-android-arm64': 4.54.0
'@rollup/rollup-darwin-arm64': 4.53.5 '@rollup/rollup-darwin-arm64': 4.54.0
'@rollup/rollup-darwin-x64': 4.53.5 '@rollup/rollup-darwin-x64': 4.54.0
'@rollup/rollup-freebsd-arm64': 4.53.5 '@rollup/rollup-freebsd-arm64': 4.54.0
'@rollup/rollup-freebsd-x64': 4.53.5 '@rollup/rollup-freebsd-x64': 4.54.0
'@rollup/rollup-linux-arm-gnueabihf': 4.53.5 '@rollup/rollup-linux-arm-gnueabihf': 4.54.0
'@rollup/rollup-linux-arm-musleabihf': 4.53.5 '@rollup/rollup-linux-arm-musleabihf': 4.54.0
'@rollup/rollup-linux-arm64-gnu': 4.53.5 '@rollup/rollup-linux-arm64-gnu': 4.54.0
'@rollup/rollup-linux-arm64-musl': 4.53.5 '@rollup/rollup-linux-arm64-musl': 4.54.0
'@rollup/rollup-linux-loong64-gnu': 4.53.5 '@rollup/rollup-linux-loong64-gnu': 4.54.0
'@rollup/rollup-linux-ppc64-gnu': 4.53.5 '@rollup/rollup-linux-ppc64-gnu': 4.54.0
'@rollup/rollup-linux-riscv64-gnu': 4.53.5 '@rollup/rollup-linux-riscv64-gnu': 4.54.0
'@rollup/rollup-linux-riscv64-musl': 4.53.5 '@rollup/rollup-linux-riscv64-musl': 4.54.0
'@rollup/rollup-linux-s390x-gnu': 4.53.5 '@rollup/rollup-linux-s390x-gnu': 4.54.0
'@rollup/rollup-linux-x64-gnu': 4.53.5 '@rollup/rollup-linux-x64-gnu': 4.54.0
'@rollup/rollup-linux-x64-musl': 4.53.5 '@rollup/rollup-linux-x64-musl': 4.54.0
'@rollup/rollup-openharmony-arm64': 4.53.5 '@rollup/rollup-openharmony-arm64': 4.54.0
'@rollup/rollup-win32-arm64-msvc': 4.53.5 '@rollup/rollup-win32-arm64-msvc': 4.54.0
'@rollup/rollup-win32-ia32-msvc': 4.53.5 '@rollup/rollup-win32-ia32-msvc': 4.54.0
'@rollup/rollup-win32-x64-gnu': 4.53.5 '@rollup/rollup-win32-x64-gnu': 4.54.0
'@rollup/rollup-win32-x64-msvc': 4.53.5 '@rollup/rollup-win32-x64-msvc': 4.54.0
fsevents: 2.3.3 fsevents: 2.3.3
scheduler@0.27.0: {} scheduler@0.27.0: {}
@@ -3741,12 +3741,12 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 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: 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/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.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)
'@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.50.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/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3787,7 +3787,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
postcss: 8.5.6 postcss: 8.5.6
rollup: 4.53.5 rollup: 4.54.0
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 25.0.3 '@types/node': 25.0.3
+72 -23
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -9,9 +9,9 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search, X } from "lucide-react"; import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App"; import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -55,9 +55,11 @@ function App() {
const [hasUpdate, setHasUpdate] = useState(false); const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null); const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.9"; const CURRENT_VERSION = "7.0";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
@@ -68,10 +70,19 @@ function App() {
useEffect(() => { useEffect(() => {
const settings = getSettings(); const initSettings = async () => {
applyThemeMode(settings.themeMode); const settings = getSettings();
applyTheme(settings.theme); applyThemeMode(settings.themeMode);
applyFont(settings.fontFamily); applyTheme(settings.theme);
applyFont(settings.fontFamily);
// Initialize default download path if not set
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
saveSettings(settingsWithDefaults);
}
};
initSettings();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => { const handleChange = () => {
@@ -86,11 +97,22 @@ function App() {
checkForUpdates(); checkForUpdates();
loadHistory(); loadHistory();
// Scroll listener for jump to top button
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => { return () => {
mediaQuery.removeEventListener("change", handleChange); mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("scroll", handleScroll);
}; };
}, []); }, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
useEffect(() => { useEffect(() => {
setSelectedTracks([]); setSelectedTracks([]);
setSearchQuery(""); setSearchQuery("");
@@ -282,10 +304,17 @@ function App() {
checkingAvailability={availability.checkingTrackId === track.spotify_id} checkingAvailability={availability.checkingTrackId === track.spotify_id}
availability={availability.getAvailability(track.spotify_id || "")} availability={availability.getAvailability(track.spotify_id || "")}
downloadingCover={cover.downloadingCover} downloadingCover={cover.downloadingCover}
downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")}
failedCover={cover.failedCovers.has(track.spotify_id || "")}
skippedCover={cover.skippedCovers.has(track.spotify_id || "")}
onDownload={download.handleDownloadTrack} onDownload={download.handleDownloadTrack}
onDownloadLyrics={lyrics.handleDownloadLyrics} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)
}
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadCover={cover.handleDownloadCover} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)
}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
/> />
); );
@@ -327,11 +356,11 @@ function App() {
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
@@ -395,11 +424,11 @@ function App() {
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)}
@@ -469,11 +498,11 @@ function App() {
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)}
@@ -629,13 +658,22 @@ function App() {
loading={metadata.loading} loading={metadata.loading}
onUrlChange={setSpotifyUrl} onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata} onFetch={handleFetchMetadata}
onFetchUrl={async (url) => {
setSpotifyUrl(url);
const updatedUrl = await metadata.handleFetchMetadata(url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
}}
history={fetchHistory} history={fetchHistory}
onHistorySelect={handleHistorySelect} onHistorySelect={handleHistorySelect}
onHistoryRemove={removeFromHistory} onHistoryRemove={removeFromHistory}
hasResult={!!metadata.metadata} hasResult={!!metadata.metadata}
searchMode={isSearchMode}
onSearchModeChange={setIsSearchMode}
/> />
{metadata.metadata && renderMetadata()} {!isSearchMode && metadata.metadata && renderMetadata()}
</> </>
); );
} }
@@ -662,6 +700,17 @@ function App() {
isOpen={downloadQueue.isOpen} isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue} onClose={downloadQueue.closeQueue}
/> />
{/* Jump to Top Button - Bottom Right */}
{showScrollTop && (
<Button
onClick={scrollToTop}
className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg"
size="icon"
>
<ArrowUp className="h-5 w-5" />
</Button>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
+2 -2
View File
@@ -52,8 +52,8 @@ interface AlbumInfoProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void; onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void; onDownloadAllCovers?: () => void;
+2 -2
View File
@@ -57,8 +57,8 @@ interface ArtistInfoProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void; onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void; onDownloadAllCovers?: () => void;
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -56,8 +56,8 @@ interface PlaylistInfoProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void; onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void; onDownloadAllCovers?: () => void;
+489 -32
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { CloudDownload, Info, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
Tooltip, Tooltip,
@@ -10,16 +10,28 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory"; import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory";
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils";
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8;
const SEARCH_LIMIT = 50;
interface SearchBarProps { interface SearchBarProps {
url: string; url: string;
loading: boolean; loading: boolean;
onUrlChange: (url: string) => void; onUrlChange: (url: string) => void;
onFetch: () => void; onFetch: () => void;
onFetchUrl: (url: string) => Promise<void>;
history: HistoryItem[]; history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void; onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void; onHistoryRemove: (id: string) => void;
hasResult: boolean; hasResult: boolean;
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
} }
export function SearchBar({ export function SearchBar({
@@ -27,68 +39,513 @@ export function SearchBar({
loading, loading,
onUrlChange, onUrlChange,
onFetch, onFetch,
onFetchUrl,
history, history,
onHistorySelect, onHistorySelect,
onHistoryRemove, onHistoryRemove,
hasResult, hasResult,
searchMode,
onSearchModeChange,
}: SearchBarProps) { }: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
tracks: false,
albums: false,
artists: false,
playlists: false,
});
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load recent searches from localStorage
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
if (saved) {
setRecentSearches(JSON.parse(saved));
}
} catch (error) {
console.error("Failed to load recent searches:", error);
}
}, []);
const saveRecentSearch = (query: string) => {
const trimmed = query.trim();
if (!trimmed) return;
setRecentSearches((prev) => {
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
const removeRecentSearch = (query: string) => {
setRecentSearches((prev) => {
const updated = prev.filter((s) => s !== query);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
// Debounced search - only search if query changed
useEffect(() => {
if (!searchMode || !searchQuery.trim()) {
return;
}
// Don't search again if query is the same
if (searchQuery.trim() === lastSearchedQuery) {
return;
}
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
setSearchResults(results);
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
// Check if there might be more results
setHasMore({
tracks: results.tracks.length === SEARCH_LIMIT,
albums: results.albums.length === SEARCH_LIMIT,
artists: results.artists.length === SEARCH_LIMIT,
playlists: results.playlists.length === SEARCH_LIMIT,
});
// Auto-select first tab with results
if (results.tracks.length > 0) setActiveTab("tracks");
else if (results.albums.length > 0) setActiveTab("albums");
else if (results.artists.length > 0) setActiveTab("artists");
else if (results.playlists.length > 0) setActiveTab("playlists");
} catch (error) {
console.error("Search failed:", error);
setSearchResults(null);
} finally {
setIsSearching(false);
}
}, 400);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchMode, lastSearchedQuery]);
const handleLoadMore = async () => {
if (!searchResults || !lastSearchedQuery || isLoadingMore) return;
const typeMap: Record<ResultTab, string> = {
tracks: "track",
albums: "album",
artists: "artist",
playlists: "playlist",
};
const currentCount = getTabCount(activeTab);
setIsLoadingMore(true);
try {
const moreResults = await SearchSpotifyByType({
query: lastSearchedQuery,
search_type: typeMap[activeTab],
limit: SEARCH_LIMIT,
offset: currentCount,
});
if (moreResults.length > 0) {
setSearchResults((prev) => {
if (!prev) return prev;
// Create new SearchResponse with updated array for the active tab
const updated = new backend.SearchResponse({
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
});
return updated;
});
}
// Update hasMore for this tab
setHasMore((prev) => ({
...prev,
[activeTab]: moreResults.length === SEARCH_LIMIT,
}));
} catch (error) {
console.error("Load more failed:", error);
} finally {
setIsLoadingMore(false);
}
};
const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false);
onFetchUrl(externalUrl);
};
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const hasAnyResults = searchResults && (
searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0
);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults) return 0;
switch (tab) {
case "tracks": return searchResults.tracks.length;
case "albums": return searchResults.albums.length;
case "artists": return searchResults.artists.length;
case "playlists": return searchResults.playlists.length;
}
};
const tabs: { key: ResultTab; label: string }[] = [
{ key: "tracks", label: "Tracks" },
{ key: "albums", label: "Albums" },
{ key: "artists", label: "Artists" },
{ key: "playlists", label: "Playlists" },
];
return ( return (
<div className="space-y-3"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label> {/* Mode Toggle */}
<div className="flex items-center bg-muted rounded-md p-1">
<button
type="button"
onClick={() => onSearchModeChange(false)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
!searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Link className="h-3.5 w-3.5" />
URL
</button>
<button
type="button"
onClick={() => onSearchModeChange(true)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Search className="h-3.5 w-3.5" />
Search
</button>
</div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" /> <Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p> {!searchMode ? (
<p className="mt-1">Note: Playlist must be public (not private)</p> <>
<p>Supports track, album, playlist, and artist URLs</p>
<p className="mt-1">Note: Playlist must be public (not private)</p>
</>
) : (
<p>Search for tracks, albums, artists, or playlists</p>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<InputWithContext {!searchMode ? (
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<> <>
<Spinner /> <InputWithContext
Fetching... id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</> </>
) : ( ) : (
<> <>
<CloudDownload className="h-4 w-4" /> <InputWithContext
Fetch id="spotify-search"
placeholder="Search tracks, albums, artists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-8"
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => {
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
}}
>
<XCircle className="h-4 w-4" />
</button>
)}
</> </>
)} )}
</Button> </div>
{!searchMode && (
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<CloudDownload className="h-4 w-4" />
Fetch
</>
)}
</Button>
)}
</div> </div>
</div> </div>
{!hasResult && (
{!searchMode && !hasResult && (
<FetchHistory <FetchHistory
history={history} history={history}
onSelect={onHistorySelect} onSelect={onHistorySelect}
onRemove={onHistoryRemove} onRemove={onHistoryRemove}
/> />
)} )}
{/* Search Results with Tabs */}
{searchMode && (
<div className="space-y-4">
{/* Recent Searches - show when no query or no results yet */}
{!searchQuery && !searchResults && recentSearches.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (
<div
key={query}
className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors"
onClick={() => setSearchQuery(query)}
>
<span>{query}</span>
<button
type="button"
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
onClick={(e) => {
e.stopPropagation();
removeRecentSearch(query);
}}
>
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
</button>
</div>
))}
</div>
</div>
)}
{isSearching && (
<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>
)}
{!isSearching && searchQuery && !hasAnyResults && (
<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>
)}
{!isSearching && hasAnyResults && (
<>
{/* Tabs */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0) return null;
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px",
activeTab === tab.key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label} ({count})
</button>
);
})}
</div>
{/* Tab Content */}
<div className="grid gap-2">
{/* Tracks */}
{activeTab === "tracks" && searchResults?.tracks.map((track) => (
<button
key={track.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(track.external_urls)}
>
{track.images ? (
<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.name}</p>
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>
))}
{/* Albums */}
{activeTab === "albums" && searchResults?.albums.map((album) => (
<button
key={album.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(album.external_urls)}
>
{album.images ? (
<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{album.total_tracks} tracks
</span>
</button>
))}
{/* Artists */}
{activeTab === "artists" && searchResults?.artists.map((artist) => (
<button
key={artist.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(artist.external_urls)}
>
{artist.images ? (
<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded-full bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p>
</div>
</button>
))}
{/* Playlists */}
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (
<button
key={playlist.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(playlist.external_urls)}
>
{playlist.images ? (
<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate">
{playlist.owner} {playlist.total_tracks} tracks
</p>
</div>
</button>
))}
</div>
{/* Load More Button */}
{hasMore[activeTab] && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Spinner />
Loading...
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Load More
</>
)}
</Button>
</div>
)}
</>
)}
</div>
)}
</div> </div>
); );
} }
+2
View File
@@ -85,6 +85,8 @@ export function SettingsPage() {
const settingsWithDefaults = await getSettingsWithDefaults(); const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults); setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults); setTempSettings(settingsWithDefaults);
// Save to localStorage so it persists on reload
saveSettings(settingsWithDefaults);
} }
}; };
loadDefaults(); loadDefaults();
+41 -38
View File
@@ -1,4 +1,3 @@
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
@@ -25,10 +24,13 @@ interface TrackInfoProps {
checkingAvailability?: boolean; checkingAvailability?: boolean;
availability?: TrackAvailability; availability?: TrackAvailability;
downloadingCover?: boolean; downloadingCover?: boolean;
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void; onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void; onOpenFolder: () => void;
} }
@@ -46,51 +48,26 @@ export function TrackInfo({
checkingAvailability, checkingAvailability,
availability, availability,
downloadingCover, downloadingCover,
downloadedCover,
failedCover,
skippedCover,
onDownload, onDownload,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability, onCheckAvailability,
onDownloadCover, onDownloadCover,
onOpenFolder, onOpenFolder,
}: TrackInfoProps) { }: TrackInfoProps) {
const [isHoveringCover, setIsHoveringCover] = useState(false);
return ( return (
<Card> <Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
<div <div className="shrink-0">
className="shrink-0 relative"
onMouseEnter={() => setIsHoveringCover(true)}
onMouseLeave={() => setIsHoveringCover(false)}
>
{track.images && ( {track.images && (
<> <img
<img src={track.images}
src={track.images} alt={track.name}
alt={track.name} className="w-48 h-48 rounded-md shadow-lg object-cover"
className="w-48 h-48 rounded-md shadow-lg object-cover" />
/>
{isHoveringCover && onDownloadCover && (
<div className="absolute inset-0 bg-black/50 rounded-md flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="secondary"
className="cursor-pointer"
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name)}
disabled={downloadingCover}
>
{downloadingCover ? <Spinner /> : <ImageDown className="h-5 w-5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
</div>
)}
</>
)} )}
</div> </div>
<div className="flex-1 space-y-4 min-w-0"> <div className="flex-1 space-y-4 min-w-0">
@@ -136,7 +113,7 @@ export function TrackInfo({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)} onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)}
variant="outline" variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id} disabled={downloadingLyricsTrack === track.spotify_id}
> >
@@ -158,6 +135,32 @@ export function TrackInfo({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{track.images && onDownloadCover && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)}
variant="outline"
disabled={downloadingCover}
>
{downloadingCover ? (
<Spinner />
) : skippedCover ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedCover ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedCover ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<ImageDown className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onCheckAvailability && ( {track.spotify_id && onCheckAvailability && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+4 -4
View File
@@ -50,9 +50,9 @@ interface TrackListProps {
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void; onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
@@ -339,7 +339,7 @@ export function TrackList({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
onClick={() => onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1) onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)
} }
size="sm" size="sm"
variant="outline" variant="outline"
@@ -369,7 +369,7 @@ export function TrackList({
<Button <Button
onClick={() => { onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`; const trackId = track.spotify_id || `${track.name}-${track.artists}`;
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId); onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
}} }}
size="sm" size="sm"
variant="outline" variant="outline"
+18 -3
View File
@@ -23,7 +23,10 @@ export function useCover() {
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
position?: number, position?: number,
trackId?: string trackId?: string,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => { ) => {
if (!coverUrl) { if (!coverUrl) {
toast.error("No cover URL found for this track"); toast.error("No cover URL found for this track");
@@ -72,10 +75,14 @@ export function useCover() {
cover_url: coverUrl, cover_url: coverUrl,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: artistName,
album_name: albumName || "",
album_artist: albumArtist || "",
release_date: releaseDate || "",
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: position || 0, position: position || 0,
disc_number: discNumber || 0,
}); });
if (response.success) { if (response.success) {
@@ -145,12 +152,16 @@ export function useCover() {
// Replace forward slashes in template data values to prevent them from being interpreted as path separators // Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
// Determine if we should use album track number or sequential position
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
// Build output path using template system // Build output path using template system
const templateData: TemplateData = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder),
track: i + 1, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
@@ -176,10 +187,14 @@ export function useCover() {
cover_url: track.images, cover_url: track.images,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: i + 1, position: trackPosition,
disc_number: track.disc_number,
}); });
if (response.success) { if (response.success) {
+22 -6
View File
@@ -21,7 +21,10 @@ export function useLyrics() {
artistName: string, artistName: string,
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
position?: number position?: number,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => { ) => {
if (!spotifyId) { if (!spotifyId) {
toast.error("No Spotify ID found for this track"); toast.error("No Spotify ID found for this track");
@@ -71,11 +74,15 @@ export function useLyrics() {
spotify_id: spotifyId, spotify_id: spotifyId,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: position || 0, position: position || 0,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
disc_number: discNumber,
}); });
if (response.success) { if (response.success) {
@@ -126,7 +133,8 @@ export function useLyrics() {
let skipped = 0; let skipped = 0;
const total = tracksWithSpotifyId.length; const total = tracksWithSpotifyId.length;
for (const track of tracksWithSpotifyId) { for (let i = 0; i < tracksWithSpotifyId.length; i++) {
const track = tracksWithSpotifyId[i];
if (stopBulkDownloadRef.current) { if (stopBulkDownloadRef.current) {
toast.info("Lyrics download stopped by user"); toast.info("Lyrics download stopped by user");
break; break;
@@ -142,12 +150,18 @@ export function useLyrics() {
// Replace forward slashes in template data values to prevent them from being interpreted as path separators // Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
// Determine if we should use album track number or sequential position
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
// Build output path using template system // Build output path using template system
const templateData: TemplateData = { const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder), artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder),
track: track.track_number, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
@@ -169,17 +183,19 @@ export function useLyrics() {
} }
} }
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({ const response = await downloadLyrics({
spotify_id: id, spotify_id: id,
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}", filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: track.track_number || 0, position: trackPosition,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
disc_number: track.disc_number,
}); });
if (response.success) { if (response.success) {
+1 -1
View File
@@ -77,7 +77,7 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
code, pre, .font-mono { code, pre, .font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
+1 -1
View File
@@ -112,7 +112,7 @@ export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: strin
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' }, { value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' }, { value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' }, { value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' }, { value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' }, { value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' }, { value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' }, { value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
+8
View File
@@ -185,11 +185,15 @@ export interface LyricsDownloadRequest {
spotify_id: string; spotify_id: string;
track_name: string; track_name: string;
artist_name: string; artist_name: string;
album_name?: string;
album_artist?: string;
release_date?: string;
output_dir?: string; output_dir?: string;
filename_format?: string; filename_format?: string;
track_number?: boolean; track_number?: boolean;
position?: number; position?: number;
use_album_track_number?: boolean; use_album_track_number?: boolean;
disc_number?: number;
} }
export interface LyricsDownloadResponse { export interface LyricsDownloadResponse {
@@ -214,10 +218,14 @@ export interface CoverDownloadRequest {
cover_url: string; cover_url: string;
track_name: string; track_name: string;
artist_name: string; artist_name: string;
album_name?: string;
album_artist?: string;
release_date?: string;
output_dir?: string; output_dir?: string;
filename_format?: string; filename_format?: string;
track_number?: boolean; track_number?: boolean;
position?: number; position?: number;
disc_number?: number;
} }
export interface CoverDownloadResponse { export interface CoverDownloadResponse {
-10
View File
@@ -1,10 +0,0 @@
[
"vogel.qqdl.site",
"maus.qqdl.site",
"hund.qqdl.site",
"katze.qqdl.site",
"wolf.qqdl.site",
"tidal.kinoplus.online",
"tidal-api.binimum.org",
"triton.squid.wtf"
]
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "6.9" "productVersion": "7.0"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",