v7.0
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
c94dda3302d3338d7909ef5d634d0fde
|
0f9764c2a4597a75120d3e76c32af7a9
|
||||||
Generated
+165
-165
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user