v7.0.7
This commit is contained in:
@@ -24,16 +24,23 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
## Other projects
|
## Other projects
|
||||||
|
|
||||||
|
### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next)
|
||||||
|
|
||||||
|
Get Spotify tracks in Hi-Res lossless FLACs — no account required.
|
||||||
|
|
||||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
|
|
||||||
|
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
|
||||||
|
|
||||||
### [SpotubeDL](https://spotubedl.com)
|
### [SpotubeDL](https://spotubedl.com)
|
||||||
|
|
||||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
|
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
|
||||||
|
|
||||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||||
|
|
||||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||||
|
|
||||||
## FAQ (Frequently Asked Questions)
|
## FAQ
|
||||||
|
|
||||||
### Is this software free?
|
### Is this software free?
|
||||||
|
|
||||||
@@ -78,6 +85,7 @@ This project is for **educational and private use only**. The developer does not
|
|||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||||
|
|
||||||
You are solely responsible for:
|
You are solely responsible for:
|
||||||
|
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
1. Ensuring your use of this software complies with your local laws.
|
||||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
2. Reading and adhering to the Terms of Service of the respective platforms.
|
||||||
3. Any legal consequences resulting from the misuse of this tool.
|
3. Any legal consequences resulting from the misuse of this tool.
|
||||||
@@ -87,8 +95,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
|||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api)
|
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api)
|
||||||
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf)
|
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev/)
|
||||||
- **Amazon Music**: [doubledouble.top](https://doubledouble.top), [lucida.to](https://lucida.to)
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ type DownloadRequest struct {
|
|||||||
SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"`
|
SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"`
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
Publisher string `json:"publisher,omitempty"`
|
Publisher string `json:"publisher,omitempty"`
|
||||||
|
PlaylistName string `json:"playlist_name,omitempty"`
|
||||||
|
PlaylistOwner string `json:"playlist_owner,omitempty"`
|
||||||
|
AllowFallback bool `json:"allow_fallback"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
@@ -90,14 +93,14 @@ type DownloadResponse struct {
|
|||||||
ItemID string `json:"item_id,omitempty"`
|
ItemID string `json:"item_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) {
|
func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) {
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
return "", fmt.Errorf("spotify track ID is required")
|
return "", fmt.Errorf("spotify track ID is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID)
|
fmt.Printf("[GetStreamingURLs] Called for track ID: %s, Region: %s\n", spotifyTrackID, region)
|
||||||
client := backend.NewSongLinkClient()
|
client := backend.NewSongLinkClient()
|
||||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID)
|
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -201,7 +204,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
req.OutputDir = "."
|
req.OutputDir = "."
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
req.OutputDir = backend.NormalizePath(req.OutputDir)
|
if req.PlaylistName != "" {
|
||||||
|
sanitizedPlaylist := backend.SanitizeFilename(req.PlaylistName)
|
||||||
|
req.OutputDir = filepath.Join(req.OutputDir, sanitizedPlaylist)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.AudioFormat == "" {
|
if req.AudioFormat == "" {
|
||||||
@@ -281,7 +289,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.TrackName != "" && req.ArtistName != "" {
|
if req.TrackName != "" && req.ArtistName != "" {
|
||||||
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
|
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, 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 {
|
||||||
@@ -301,8 +309,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
case "amazon":
|
case "amazon":
|
||||||
downloader := backend.NewAmazonDownloader()
|
downloader := backend.NewAmazonDownloader()
|
||||||
if req.ServiceURL != "" {
|
if req.ServiceURL != "" {
|
||||||
|
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
||||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
|
||||||
} else {
|
} else {
|
||||||
if req.SpotifyID == "" {
|
if req.SpotifyID == "" {
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -310,15 +317,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
Error: "Spotify ID is required for Amazon Music",
|
Error: "Spotify ID is required for Amazon Music",
|
||||||
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
||||||
}
|
}
|
||||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tidal":
|
case "tidal":
|
||||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||||
downloader := backend.NewTidalDownloader("")
|
downloader := backend.NewTidalDownloader("")
|
||||||
if req.ServiceURL != "" {
|
if req.ServiceURL != "" {
|
||||||
|
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
||||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
|
||||||
} else {
|
} else {
|
||||||
if req.SpotifyID == "" {
|
if req.SpotifyID == "" {
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -326,14 +332,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
Error: "Spotify ID is required for Tidal",
|
Error: "Spotify ID is required for Tidal",
|
||||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||||
}
|
}
|
||||||
|
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||||
if req.ServiceURL != "" {
|
if req.ServiceURL != "" {
|
||||||
|
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
||||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
|
||||||
} else {
|
} else {
|
||||||
if req.SpotifyID == "" {
|
if req.SpotifyID == "" {
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -341,8 +345,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
Error: "Spotify ID is required for Tidal",
|
Error: "Spotify ID is required for Tidal",
|
||||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||||
}
|
}
|
||||||
|
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +387,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
Error: "ISRC is required for Qobuz (could not fetch from Deezer)",
|
Error: "ISRC is required for Qobuz (could not fetch from Deezer)",
|
||||||
}, fmt.Errorf("ISRC is required for Qobuz")
|
}, fmt.Errorf("ISRC is required for Qobuz")
|
||||||
}
|
}
|
||||||
filename, err = downloader.DownloadByISRC(deezerISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
filename, err = downloader.DownloadByISRC(deezerISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -513,6 +516,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
item.Format = strings.ToUpper(ext[1:])
|
item.Format = strings.ToUpper(ext[1:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch item.Format {
|
||||||
|
case "6", "7", "27":
|
||||||
|
item.Format = "FLAC"
|
||||||
|
}
|
||||||
|
|
||||||
backend.AddHistoryItem(item, "SpotiFLAC")
|
backend.AddHistoryItem(item, "SpotiFLAC")
|
||||||
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat)
|
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat)
|
||||||
}
|
}
|
||||||
@@ -596,6 +605,30 @@ func (a *App) ClearDownloadHistory() error {
|
|||||||
return backend.ClearHistory("SpotiFLAC")
|
return backend.ClearHistory("SpotiFLAC")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteDownloadHistoryItem(id string) error {
|
||||||
|
return backend.DeleteHistoryItem(id, "SpotiFLAC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetFetchHistory() ([]backend.FetchHistoryItem, error) {
|
||||||
|
return backend.GetFetchHistoryItems("SpotiFLAC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AddFetchHistory(item backend.FetchHistoryItem) error {
|
||||||
|
return backend.AddFetchHistoryItem(item, "SpotiFLAC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ClearFetchHistory() error {
|
||||||
|
return backend.ClearFetchHistory("SpotiFLAC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteFetchHistoryItem(id string) error {
|
||||||
|
return backend.DeleteFetchHistoryItem(id, "SpotiFLAC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ClearFetchHistoryByType(itemType string) error {
|
||||||
|
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
||||||
if filePath == "" {
|
if filePath == "" {
|
||||||
return "", fmt.Errorf("file path is required")
|
return "", fmt.Errorf("file path is required")
|
||||||
@@ -987,6 +1020,27 @@ func (a *App) RenameFileTo(oldPath, newName string) error {
|
|||||||
return os.Rename(oldPath, newPath)
|
return os.Rename(oldPath, newPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) UploadImage(filePath string) (string, error) {
|
||||||
|
return backend.UploadToSendNow(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) UploadImageBytes(filename string, base64Data string) (string, error) {
|
||||||
|
|
||||||
|
if idx := strings.Index(base64Data, ","); idx != -1 {
|
||||||
|
base64Data = base64Data[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode base64: %v", err)
|
||||||
|
}
|
||||||
|
return backend.UploadBytesToSendNow(filename, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SelectImageVideo() ([]string, error) {
|
||||||
|
return backend.SelectImageVideoDialog(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ReadImageAsBase64(filePath string) (string, error) {
|
func (a *App) ReadImageAsBase64(filePath string) (string, error) {
|
||||||
content, err := os.ReadFile(filePath)
|
content, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1026,6 +1080,7 @@ type CheckFileExistenceRequest struct {
|
|||||||
FilenameFormat string `json:"filename_format,omitempty"`
|
FilenameFormat string `json:"filename_format,omitempty"`
|
||||||
IncludeTrackNumber bool `json:"include_track_number,omitempty"`
|
IncludeTrackNumber bool `json:"include_track_number,omitempty"`
|
||||||
AudioFormat string `json:"audio_format,omitempty"`
|
AudioFormat string `json:"audio_format,omitempty"`
|
||||||
|
RelativePath string `json:"relative_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckFileExistenceResult struct {
|
type CheckFileExistenceResult struct {
|
||||||
@@ -1088,6 +1143,8 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
|||||||
t.AlbumArtist,
|
t.AlbumArtist,
|
||||||
t.ReleaseDate,
|
t.ReleaseDate,
|
||||||
filenameFormat,
|
filenameFormat,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
t.IncludeTrackNumber,
|
t.IncludeTrackNumber,
|
||||||
trackNumber,
|
trackNumber,
|
||||||
t.DiscNumber,
|
t.DiscNumber,
|
||||||
@@ -1096,7 +1153,12 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
|||||||
|
|
||||||
expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt
|
expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt
|
||||||
|
|
||||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
targetDir := outputDir
|
||||||
|
if t.RelativePath != "" {
|
||||||
|
targetDir = filepath.Join(outputDir, t.RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(targetDir, 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 {
|
||||||
res.Exists = true
|
res.Exists = true
|
||||||
|
|||||||
+59
-444
@@ -1,15 +1,11 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,11 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AmazonDownloader struct {
|
type AmazonDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
regions []string
|
regions []string
|
||||||
lastAPICallTime time.Time
|
|
||||||
apiCallCount int
|
|
||||||
apiCallResetTime time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SongLinkResponse struct {
|
type SongLinkResponse struct {
|
||||||
@@ -32,35 +25,13 @@ type SongLinkResponse struct {
|
|||||||
} `json:"linksByPlatform"`
|
} `json:"linksByPlatform"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DoubleDoubleSubmitResponse struct {
|
type AfkarXYZResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
ID string `json:"id"`
|
Data struct {
|
||||||
}
|
DirectLink string `json:"direct_link"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
type DoubleDoubleStatusResponse struct {
|
FileSize int64 `json:"file_size"`
|
||||||
Status string `json:"status"`
|
} `json:"data"`
|
||||||
FriendlyStatus string `json:"friendlyStatus"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Current struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
} `json:"current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LucidaLoadResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Server string `json:"server"`
|
|
||||||
Handoff string `json:"handoff"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LucidaStatusResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Progress struct {
|
|
||||||
Current int64 `json:"current"`
|
|
||||||
Total int64 `json:"total"`
|
|
||||||
} `json:"progress"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
@@ -68,93 +39,35 @@ func NewAmazonDownloader() *AmazonDownloader {
|
|||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
},
|
},
|
||||||
regions: []string{"us", "eu"},
|
regions: []string{"us", "eu"},
|
||||||
apiCallResetTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) getRandomUserAgent() string {
|
|
||||||
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
|
||||||
rand.Intn(4)+11, rand.Intn(5)+4,
|
|
||||||
rand.Intn(7)+530, rand.Intn(7)+30,
|
|
||||||
rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60,
|
|
||||||
rand.Intn(7)+530, rand.Intn(6)+30)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
now := time.Now()
|
spotifyBase := "https://open.spotify.com/track/"
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
||||||
a.apiCallCount = 0
|
|
||||||
a.apiCallResetTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.apiCallCount >= 9 {
|
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
||||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
||||||
if waitTime > 0 {
|
|
||||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
a.apiCallCount = 0
|
|
||||||
a.apiCallResetTime = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !a.lastAPICallTime.IsZero() {
|
|
||||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
|
||||||
minDelay := 7 * time.Second
|
|
||||||
if timeSinceLastCall < minDelay {
|
|
||||||
waitTime := minDelay - timeSinceLastCall
|
|
||||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
|
||||||
|
|
||||||
fmt.Println("Getting Amazon URL...")
|
fmt.Println("Getting Amazon URL...")
|
||||||
|
|
||||||
maxRetries := 3
|
resp, err := a.client.Do(req)
|
||||||
var resp *http.Response
|
if err != nil {
|
||||||
for i := 0; i < maxRetries; i++ {
|
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||||
resp, err = a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.lastAPICallTime = time.Now()
|
|
||||||
a.apiCallCount++
|
|
||||||
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
resp.Body.Close()
|
|
||||||
if i < maxRetries-1 {
|
|
||||||
waitTime := 15 * time.Second
|
|
||||||
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resp.Body.Close()
|
|
||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||||
@@ -194,177 +107,65 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
return amazonURL, nil
|
return amazonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) extractData(html string, patterns []string) string {
|
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
||||||
for _, p := range patterns {
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
re := regexp.MustCompile(p)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
matches := re.FindStringSubmatch(html)
|
|
||||||
if len(matches) > 1 {
|
|
||||||
return matches[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality string) (string, error) {
|
|
||||||
tr := &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
}
|
|
||||||
jar, _ := cookiejar.New(nil)
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: tr,
|
|
||||||
Jar: jar,
|
|
||||||
Timeout: 120 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
userAgent := a.getRandomUserAgent()
|
|
||||||
|
|
||||||
fmt.Printf("Initializing lucida for Amazon Music... (Target: %s)\n", amazonURL)
|
|
||||||
lucidaBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vP3VybD0lcyZjb3VudHJ5PWF1dG8=")
|
|
||||||
lucidaURL := fmt.Sprintf(string(lucidaBase), url.QueryEscape(amazonURL))
|
|
||||||
req, _ := http.NewRequest("GET", lucidaURL, nil)
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
fmt.Printf("Fetching from AfkarXYZ...\n")
|
||||||
html := string(bodyBytes)
|
resp, err := a.client.Do(req)
|
||||||
|
|
||||||
token := a.extractData(html, []string{`token:"([^"]+)"`, `"token"\s*:\s*"([^"]+)"`})
|
|
||||||
streamURL := a.extractData(html, []string{`"url":"([^"]+)"`, `url:"([^"]+)"`})
|
|
||||||
expiry := a.extractData(html, []string{`tokenExpiry:(\d+)`, `"tokenExpiry"\s*:\s*(\d+)`})
|
|
||||||
|
|
||||||
if token == "" || streamURL == "" {
|
|
||||||
errorMsg := a.extractData(html, []string{`error:"([^"]+)"`, `"error"\s*:\s*"([^"]+)"`})
|
|
||||||
if errorMsg != "" {
|
|
||||||
return "", fmt.Errorf("lucida error: %s", errorMsg)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("could not extract required data from lucida")
|
|
||||||
}
|
|
||||||
|
|
||||||
decodedToken := token
|
|
||||||
if secondBase64, err := base64.StdEncoding.DecodeString(token); err == nil {
|
|
||||||
if firstBase64, err := base64.StdEncoding.DecodeString(string(secondBase64)); err == nil {
|
|
||||||
decodedToken = string(firstBase64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
streamURL = strings.ReplaceAll(streamURL, `\/`, `/`)
|
|
||||||
fmt.Printf("Fetching Amazon stream via Lucida...\n")
|
|
||||||
|
|
||||||
loadPayload := map[string]interface{}{
|
|
||||||
"account": map[string]string{"id": "auto", "type": "country"},
|
|
||||||
"compat": "false", "downscale": "original", "handoff": true,
|
|
||||||
"metadata": true, "private": true,
|
|
||||||
"token": map[string]interface{}{"primary": decodedToken, "expiry": expiry},
|
|
||||||
"upload": map[string]bool{"enabled": false},
|
|
||||||
"url": streamURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadBytes, _ := json.Marshal(loadPayload)
|
|
||||||
loadAPI, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vYXBpL2xvYWQ/dXJsPS9hcGkvZmV0Y2gvc3RyZWFtL3Yy")
|
|
||||||
req, _ = http.NewRequest("POST", string(loadAPI), bytes.NewBuffer(payloadBytes))
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
for _, cookie := range client.Jar.Cookies(req.URL) {
|
|
||||||
if cookie.Name == "csrf_token" {
|
|
||||||
req.Header.Set("X-CSRF-Token", cookie.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var loadData LucidaLoadResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&loadData)
|
|
||||||
|
|
||||||
if !loadData.Success {
|
|
||||||
return "", fmt.Errorf("lucida load request failed: %s", loadData.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
|
||||||
completionBase, _ := base64.StdEncoding.DecodeString("Lmx1Y2lkYS50by9hcGkvZmV0Y2gvcmVxdWVzdC8=")
|
|
||||||
completionURL := fmt.Sprintf("%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff)
|
|
||||||
fmt.Println("Processing on Lucida server...")
|
|
||||||
|
|
||||||
var finalStatus LucidaStatusResponse
|
|
||||||
for {
|
|
||||||
req, _ = http.NewRequest("GET", completionURL, nil)
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
resp, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
json.NewDecoder(resp.Body).Decode(&finalStatus)
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if finalStatus.Status == "completed" {
|
|
||||||
fmt.Println("\nTrack processing completed!")
|
|
||||||
break
|
|
||||||
} else if finalStatus.Status == "error" {
|
|
||||||
return "", fmt.Errorf("lucida processing failed: %s", finalStatus.Message)
|
|
||||||
} else if finalStatus.Progress.Total > 0 {
|
|
||||||
percent := (finalStatus.Progress.Current * 100) / finalStatus.Progress.Total
|
|
||||||
fmt.Printf("\rLucida Progress: %d%%", percent)
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSuffix, _ := base64.StdEncoding.DecodeString("L2Rvd25sb2Fk")
|
|
||||||
downloadURL := fmt.Sprintf("%s%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff, string(downloadSuffix))
|
|
||||||
req, _ = http.NewRequest("GET", downloadURL, nil)
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
resp, err = client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", fmt.Errorf("lucida download failed with status %d", resp.StatusCode)
|
return "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName := "track.flac"
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
contentDisp := resp.Header.Get("Content-Disposition")
|
var apiResp AfkarXYZResponse
|
||||||
if contentDisp != "" {
|
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||||
re := regexp.MustCompile(`filename[*]?=([^;]+)`)
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
if matches := re.FindStringSubmatch(contentDisp); len(matches) > 1 {
|
|
||||||
rawName := strings.Trim(matches[1], `"'`)
|
|
||||||
if strings.HasPrefix(rawName, "UTF-8''") {
|
|
||||||
decodedName, _ := url.PathUnescape(rawName[7:])
|
|
||||||
fileName = decodedName
|
|
||||||
} else {
|
|
||||||
fileName = rawName
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
||||||
|
return "", fmt.Errorf("AfkarXYZ failed or no link found")
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := apiResp.Data.DirectLink
|
||||||
|
fileName := apiResp.Data.FileName
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = "track.flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
filePath := filepath.Join(outputDir, fileName)
|
filePath := filepath.Join(outputDir, fileName)
|
||||||
|
|
||||||
out, err := os.Create(filePath)
|
out, err := os.Create(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
fmt.Printf("Downloading from Lucida: %s\n", fileName)
|
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
||||||
|
|
||||||
|
dlResp, err := a.client.Do(dlReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer dlResp.Body.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Downloading from AfkarXYZ: %s\n", fileName)
|
||||||
pw := NewProgressWriter(out)
|
pw := NewProgressWriter(out)
|
||||||
_, err = io.Copy(pw, resp.Body)
|
_, err = io.Copy(pw, dlResp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(filePath)
|
os.Remove(filePath)
|
||||||
return "", fmt.Errorf("failed to write file: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
@@ -372,196 +173,10 @@ func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
||||||
fmt.Println("Attempting download via Lucida (Priority)...")
|
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
||||||
filePath, err := a.DownloadFromLucida(amazonURL, outputDir, quality)
|
|
||||||
if err == nil {
|
|
||||||
return filePath, nil
|
|
||||||
}
|
|
||||||
fmt.Printf("Lucida failed: %v\nTrying Double-Double as fallback...\n", err)
|
|
||||||
|
|
||||||
var lastError error
|
|
||||||
lastError = err
|
|
||||||
|
|
||||||
for _, region := range a.regions {
|
|
||||||
fmt.Printf("\nTrying region: %s...\n", region)
|
|
||||||
|
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
|
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
|
||||||
|
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", submitURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
|
||||||
|
|
||||||
fmt.Println("Submitting download request...")
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var submitResp DoubleDoubleSubmitResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if !submitResp.Success || submitResp.ID == "" {
|
|
||||||
lastError = fmt.Errorf("submit request failed")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
|
||||||
fmt.Printf("Download ID: %s\n", downloadID)
|
|
||||||
|
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
|
||||||
fmt.Println("Waiting for download to complete...")
|
|
||||||
|
|
||||||
maxWait := 300 * time.Second
|
|
||||||
elapsed := time.Duration(0)
|
|
||||||
pollInterval := 3 * time.Second
|
|
||||||
|
|
||||||
for elapsed < maxWait {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
elapsed += pollInterval
|
|
||||||
|
|
||||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
statusReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
|
||||||
|
|
||||||
statusResp, err := a.client.Do(statusReq)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("\rStatus check failed, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if statusResp.StatusCode != 200 {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var status DoubleDoubleStatusResponse
|
|
||||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\rInvalid JSON response, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
statusResp.Body.Close()
|
|
||||||
|
|
||||||
if status.Status == "done" {
|
|
||||||
fmt.Println("\nDownload ready!")
|
|
||||||
|
|
||||||
fileURL := status.URL
|
|
||||||
if strings.HasPrefix(fileURL, "./") {
|
|
||||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
|
||||||
} else if strings.HasPrefix(fileURL, "/") {
|
|
||||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
trackName := status.Current.Name
|
|
||||||
artist := status.Current.Artist
|
|
||||||
|
|
||||||
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
|
|
||||||
|
|
||||||
downloadReq, err := http.NewRequest("GET", fileURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to create download request: %w", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
|
||||||
|
|
||||||
fileResp, err := a.client.Do(downloadReq)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to download file: %w", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
defer fileResp.Body.Close()
|
|
||||||
|
|
||||||
if fileResp.StatusCode != 200 {
|
|
||||||
lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
|
|
||||||
for _, char := range `<>:"/\|?*` {
|
|
||||||
fileName = strings.ReplaceAll(fileName, string(char), "")
|
|
||||||
}
|
|
||||||
fileName = strings.TrimSpace(fileName)
|
|
||||||
|
|
||||||
filePath := filepath.Join(outputDir, fileName)
|
|
||||||
|
|
||||||
out, err := os.Create(filePath)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to create file: %w", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
fmt.Println("Downloading...")
|
|
||||||
|
|
||||||
pw := NewProgressWriter(out)
|
|
||||||
_, err = io.Copy(pw, fileResp.Body)
|
|
||||||
if err != nil {
|
|
||||||
out.Close()
|
|
||||||
return "", fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
|
||||||
fmt.Println("Download complete!")
|
|
||||||
return filePath, nil
|
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
|
||||||
errorMsg := status.FriendlyStatus
|
|
||||||
if errorMsg == "" {
|
|
||||||
errorMsg = "Unknown error"
|
|
||||||
}
|
|
||||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
|
|
||||||
friendlyStatus := status.FriendlyStatus
|
|
||||||
if friendlyStatus == "" {
|
|
||||||
friendlyStatus = status.Status
|
|
||||||
}
|
|
||||||
fmt.Printf("\r%s...", friendlyStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if elapsed >= maxWait {
|
|
||||||
lastError = fmt.Errorf("download timeout")
|
|
||||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastError != nil {
|
|
||||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||||
|
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
@@ -570,7 +185,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
}
|
}
|
||||||
|
|
||||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
|
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, 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 {
|
||||||
@@ -696,12 +311,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||||
|
|
||||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-8
@@ -9,12 +9,15 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
||||||
|
|
||||||
safeTitle := sanitizeFilename(trackName)
|
safeTitle := SanitizeFilename(trackName)
|
||||||
safeArtist := sanitizeFilename(artistName)
|
safeArtist := SanitizeFilename(artistName)
|
||||||
safeAlbum := sanitizeFilename(albumName)
|
safeAlbum := SanitizeFilename(albumName)
|
||||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
safeAlbumArtist := SanitizeFilename(albumArtist)
|
||||||
|
|
||||||
|
safePlaylist := SanitizeFilename(playlistName)
|
||||||
|
safeCreator := SanitizeFilename(playlistOwner)
|
||||||
|
|
||||||
year := ""
|
year := ""
|
||||||
if len(releaseDate) >= 4 {
|
if len(releaseDate) >= 4 {
|
||||||
@@ -30,6 +33,8 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
||||||
|
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
@@ -64,7 +69,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
|||||||
return filename + ".flac"
|
return filename + ".flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeFilename(name string) string {
|
func SanitizeFilename(name string) string {
|
||||||
|
|
||||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||||
|
|
||||||
@@ -148,7 +153,8 @@ func SanitizeFolderPath(folderPath string) string {
|
|||||||
return strings.Join(sanitizedParts, sep)
|
return strings.Join(sanitizedParts, sep)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeFolderName(name string) string {
|
func sanitizeFolderName(name string) string { return SanitizeFilename(name) }
|
||||||
|
|
||||||
return sanitizeFilename(name)
|
func sanitizeFilename(name string) string {
|
||||||
|
return SanitizeFilename(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,26 @@ func SelectFileDialog(ctx context.Context) (string, error) {
|
|||||||
|
|
||||||
return selectedFile, nil
|
return selectedFile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SelectImageVideoDialog(ctx context.Context) ([]string, error) {
|
||||||
|
options := wailsRuntime.OpenDialogOptions{
|
||||||
|
Title: "Select Image or Video",
|
||||||
|
Filters: []wailsRuntime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)",
|
||||||
|
Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "All Files (*.*)",
|
||||||
|
Pattern: "*.*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedPaths, nil
|
||||||
|
}
|
||||||
|
|||||||
+178
-1
@@ -75,7 +75,10 @@ func AddHistoryItem(item HistoryItem, appName string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||||
b := tx.Bucket([]byte(historyBucket))
|
b, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
id, _ := b.NextSequence()
|
id, _ := b.NextSequence()
|
||||||
|
|
||||||
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
|
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
|
||||||
@@ -147,3 +150,177 @@ func ClearHistory(appName string) error {
|
|||||||
return tx.DeleteBucket([]byte(historyBucket))
|
return tx.DeleteBucket([]byte(historyBucket))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FetchHistoryItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Info string `json:"info"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
fetchHistoryBucket = "FetchHistory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddFetchHistoryItem(item FetchHistoryItem, appName string) error {
|
||||||
|
if historyDB == nil {
|
||||||
|
if err := InitHistoryDB(appName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, _ := b.NextSequence()
|
||||||
|
|
||||||
|
if item.URL != "" {
|
||||||
|
c := b.Cursor()
|
||||||
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
|
var existing FetchHistoryItem
|
||||||
|
if err := json.Unmarshal(v, &existing); err == nil {
|
||||||
|
if existing.URL == item.URL && existing.Type == item.Type {
|
||||||
|
if err := b.Delete(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
|
||||||
|
item.Timestamp = time.Now().Unix()
|
||||||
|
|
||||||
|
buf, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.Stats().KeyN >= maxHistory {
|
||||||
|
c := b.Cursor()
|
||||||
|
toDelete := maxHistory / 20
|
||||||
|
if toDelete < 1 {
|
||||||
|
toDelete = 1
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
|
||||||
|
if err := b.Delete(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Put([]byte(item.ID), buf)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) {
|
||||||
|
if historyDB == nil {
|
||||||
|
if err := InitHistoryDB(appName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var items []FetchHistoryItem
|
||||||
|
err := historyDB.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := b.Cursor()
|
||||||
|
|
||||||
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
|
var item FetchHistoryItem
|
||||||
|
if err := json.Unmarshal(v, &item); err == nil {
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].Timestamp > items[j].Timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearFetchHistory(appName string) error {
|
||||||
|
if historyDB == nil {
|
||||||
|
if err := InitHistoryDB(appName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||||
|
return tx.DeleteBucket([]byte(fetchHistoryBucket))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearFetchHistoryByType(itemType string, appName string) error {
|
||||||
|
if historyDB == nil {
|
||||||
|
if err := InitHistoryDB(appName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var keysToDelete [][]byte
|
||||||
|
|
||||||
|
c := b.Cursor()
|
||||||
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
|
var item FetchHistoryItem
|
||||||
|
if err := json.Unmarshal(v, &item); err == nil {
|
||||||
|
if item.Type == itemType {
|
||||||
|
keysToDelete = append(keysToDelete, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range keysToDelete {
|
||||||
|
if err := b.Delete(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteHistoryItem(id string, appName string) error {
|
||||||
|
if historyDB == nil {
|
||||||
|
if err := InitHistoryDB(appName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(historyBucket))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Delete([]byte(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteFetchHistoryItem(id string, appName string) error {
|
||||||
|
if historyDB == nil {
|
||||||
|
if err := InitHistoryDB(appName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.Delete([]byte(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+167
-78
@@ -1,10 +1,10 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -78,9 +78,8 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||||
|
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
|
||||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
|
||||||
|
|
||||||
resp, err := q.client.Get(url)
|
resp, err := q.client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -119,104 +118,194 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return &searchResp.Tracks.Items[0], nil
|
return &searchResp.Tracks.Items[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func decodeXOR(data []byte) string {
|
||||||
|
text := string(data)
|
||||||
qualityCode := quality
|
runes := []rune(text)
|
||||||
if qualityCode == "" {
|
result := make([]rune, len(runes))
|
||||||
qualityCode = "6"
|
for i, char := range runes {
|
||||||
|
key := rune((i * 17) % 128)
|
||||||
|
result[i] = char ^ 253 ^ key
|
||||||
}
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
func (q *QobuzDownloader) mapJumoQuality(quality string) int {
|
||||||
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit\n")
|
switch quality {
|
||||||
|
case "6":
|
||||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
return 6
|
||||||
|
case "7":
|
||||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
return 7
|
||||||
fmt.Printf("Trying Primary API: %s\n", primaryURL)
|
case "27":
|
||||||
|
return 27
|
||||||
resp, err := q.client.Get(primaryURL)
|
default:
|
||||||
if err == nil && resp.StatusCode == 200 {
|
return 6
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
fmt.Printf("Primary API response: %s\n", string(body))
|
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
|
||||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
|
||||||
fmt.Printf("✓ Got download URL from Primary API\n")
|
|
||||||
return streamResp.URL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Primary API failed, trying Fallback API #1...")
|
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
formatID := q.mapJumoQuality(quality)
|
||||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
region := "US"
|
||||||
|
url := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
resp, err = q.client.Get(fallbackURL)
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
if err == nil && resp.StatusCode == 200 {
|
resp, err := client.Get(url)
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err == nil && len(body) > 0 {
|
|
||||||
fmt.Printf("Fallback API #1 response: %s\n", string(body))
|
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
|
||||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
|
||||||
fmt.Printf("✓ Got download URL from Fallback API #1\n")
|
|
||||||
return streamResp.URL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Fallback API #1 failed, trying Fallback API #2...")
|
|
||||||
fallback2Base, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9xb2J1ei5zcXVpZC53dGYvYXBpL2Rvd25sb2FkLW11c2ljP3RyYWNrX2lkPQ==")
|
|
||||||
fallback2URL := fmt.Sprintf("%s%d&quality=%s", string(fallback2Base), trackID, qualityCode)
|
|
||||||
|
|
||||||
resp, err = q.client.Get(fallback2URL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("all APIs failed to get download URL: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body))
|
|
||||||
return "", fmt.Errorf("all APIs returned non-200 status")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
|
||||||
|
decoded := decodeXOR(body)
|
||||||
|
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse JSON (plain or XOR): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
|
||||||
|
return urlVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||||
|
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
|
||||||
|
return urlVal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
|
||||||
|
return linkVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("URL not found in Jumo response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||||
|
apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||||
|
resp, err := q.client.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return "", fmt.Errorf("API returned empty response")
|
return "", fmt.Errorf("empty body")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Fallback API #2 response: %s\n", string(body))
|
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
var streamResp QobuzStreamResponse
|
||||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||||
|
return streamResp.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
bodyStr := string(body)
|
return "", fmt.Errorf("invalid response")
|
||||||
if len(bodyStr) > 200 {
|
}
|
||||||
bodyStr = bodyStr[:200] + "..."
|
|
||||||
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||||
|
qualityCode := quality
|
||||||
|
if qualityCode == "" || qualityCode == "5" {
|
||||||
|
qualityCode = "6"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||||
|
|
||||||
|
standardAPIs := []string{
|
||||||
|
"https://dab.yeet.su/api/stream?trackId=",
|
||||||
|
"https://dabmusic.xyz/api/stream?trackId=",
|
||||||
|
"https://qobuz.squid.wtf/api/download-music?track_id=",
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFunc := func(qual string) (string, error) {
|
||||||
|
type Provider struct {
|
||||||
|
Name string
|
||||||
|
Func func() (string, error)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
|
||||||
|
var providers []Provider
|
||||||
|
|
||||||
|
for _, api := range standardAPIs {
|
||||||
|
currentAPI := api
|
||||||
|
providers = append(providers, Provider{
|
||||||
|
Name: "Standard(" + currentAPI + ")",
|
||||||
|
Func: func() (string, error) {
|
||||||
|
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
providers = append(providers, Provider{
|
||||||
|
Name: "Jumo-DL",
|
||||||
|
Func: func() (string, error) {
|
||||||
|
return q.DownloadFromJumo(trackID, qual)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] })
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, p := range providers {
|
||||||
|
|
||||||
|
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||||
|
|
||||||
|
url, err := p.Func()
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf("✓ Success\n")
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Provider failed: %v\n", err)
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if streamResp.URL == "" {
|
url, err := downloadFunc(qualityCode)
|
||||||
return "", fmt.Errorf("no download URL available from any API")
|
if err == nil {
|
||||||
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Got download URL from Fallback API #2\n")
|
currentQuality := qualityCode
|
||||||
return streamResp.URL, nil
|
|
||||||
|
if currentQuality == "27" && allowFallback {
|
||||||
|
fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
|
||||||
|
url, err := downloadFunc("7")
|
||||||
|
if err == nil {
|
||||||
|
fmt.Println("✓ Success with fallback quality 7")
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentQuality = "7"
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentQuality == "7" && allowFallback {
|
||||||
|
fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
|
||||||
|
url, err := downloadFunc("6")
|
||||||
|
if err == nil {
|
||||||
|
fmt.Println("✓ Success with fallback quality 6")
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||||
@@ -334,7 +423,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
|||||||
return filename + ".flac"
|
return filename + ".flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
||||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||||
|
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
@@ -362,7 +451,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
|||||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||||
|
|
||||||
fmt.Println("Getting download URL...")
|
fmt.Println("Getting download URL...")
|
||||||
downloadURL, err := q.GetDownloadURL(track.ID, quality)
|
downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -42,7 +42,7 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
|
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) {
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||||
@@ -76,6 +76,10 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
|||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||||
|
|
||||||
|
if region != "" {
|
||||||
|
apiURL += fmt.Sprintf("&userCountry=%s", region)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
|||||||
+56
-32
@@ -112,7 +112,7 @@ func (c *SpotifyClient) getAccessToken() error {
|
|||||||
q.Add("totpServer", totpCode)
|
q.Add("totpServer", totpCode)
|
||||||
req.URL.RawQuery = q.Encode()
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
@@ -149,7 +149,7 @@ func (c *SpotifyClient) getSessionInfo() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
for name, value := range c.cookies {
|
for name, value := range c.cookies {
|
||||||
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
||||||
@@ -230,7 +230,7 @@ func (c *SpotifyClient) getClientToken() error {
|
|||||||
req.Header.Set("Authority", "clienttoken.spotify.com")
|
req.Header.Set("Authority", "clienttoken.spotify.com")
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -288,7 +288,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf
|
|||||||
req.Header.Set("Client-Token", c.clientToken)
|
req.Header.Set("Client-Token", c.clientToken)
|
||||||
req.Header.Set("Spotify-App-Version", c.clientVersion)
|
req.Header.Set("Spotify-App-Version", c.clientVersion)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -772,18 +772,22 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
totalDiscs = discInfo["totalDiscs"].(int)
|
totalDiscs = discInfo["totalDiscs"].(int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentRating := getMap(trackData, "contentRating")
|
||||||
|
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||||
|
|
||||||
filtered := map[string]interface{}{
|
filtered := map[string]interface{}{
|
||||||
"id": getString(trackData, "id"),
|
"id": getString(trackData, "id"),
|
||||||
"name": getString(trackData, "name"),
|
"name": getString(trackData, "name"),
|
||||||
"artists": artistsString,
|
"artists": artistsString,
|
||||||
"album": albumInfo,
|
"album": albumInfo,
|
||||||
"duration": durationString,
|
"duration": durationString,
|
||||||
"track": int(getFloat64(trackData, "trackNumber")),
|
"track": int(getFloat64(trackData, "trackNumber")),
|
||||||
"disc": discNumber,
|
"disc": discNumber,
|
||||||
"discs": totalDiscs,
|
"discs": totalDiscs,
|
||||||
"copyright": copyrightString,
|
"copyright": copyrightString,
|
||||||
"plays": getString(trackData, "playcount"),
|
"plays": getString(trackData, "playcount"),
|
||||||
"cover": cover,
|
"cover": cover,
|
||||||
|
"is_explicit": isExplicit,
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
@@ -871,13 +875,17 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
trackID = parts[len(parts)-1]
|
trackID = parts[len(parts)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentRating := getMap(track, "contentRating")
|
||||||
|
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||||
|
|
||||||
trackInfo := map[string]interface{}{
|
trackInfo := map[string]interface{}{
|
||||||
"id": trackID,
|
"id": trackID,
|
||||||
"name": getString(track, "name"),
|
"name": getString(track, "name"),
|
||||||
"artists": trackArtistsString,
|
"artists": trackArtistsString,
|
||||||
"artistIds": artistIDs,
|
"artistIds": artistIDs,
|
||||||
"duration": durationString,
|
"duration": durationString,
|
||||||
"plays": getString(track, "playcount"),
|
"plays": getString(track, "playcount"),
|
||||||
|
"is_explicit": isExplicit,
|
||||||
}
|
}
|
||||||
tracks = append(tracks, trackInfo)
|
tracks = append(tracks, trackInfo)
|
||||||
}
|
}
|
||||||
@@ -1092,6 +1100,9 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentRating := getMap(trackData, "contentRating")
|
||||||
|
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||||
|
|
||||||
trackInfo := map[string]interface{}{
|
trackInfo := map[string]interface{}{
|
||||||
"id": trackID,
|
"id": trackID,
|
||||||
"cover": trackCover,
|
"cover": trackCover,
|
||||||
@@ -1104,6 +1115,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
"albumArtist": albumArtistsString,
|
"albumArtist": albumArtistsString,
|
||||||
"albumId": albumID,
|
"albumId": albumID,
|
||||||
"duration": durationString,
|
"duration": durationString,
|
||||||
|
"is_explicit": isExplicit,
|
||||||
}
|
}
|
||||||
tracks = append(tracks, trackInfo)
|
tracks = append(tracks, trackInfo)
|
||||||
}
|
}
|
||||||
@@ -1197,12 +1209,20 @@ func extractRelease(release map[string]interface{}) map[string]interface{} {
|
|||||||
year = yearVal
|
year = yearVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var totalTracks int
|
||||||
|
tracksInfo := getMap(release, "tracks")
|
||||||
|
if tracksInfo != nil {
|
||||||
|
totalTracks = int(getFloat64(tracksInfo, "totalCount"))
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": releaseID,
|
"id": releaseID,
|
||||||
"name": getString(release, "name"),
|
"name": getString(release, "name"),
|
||||||
"cover": cover,
|
"cover": cover,
|
||||||
"date": releaseDate,
|
"date": releaseDate,
|
||||||
"year": year,
|
"year": year,
|
||||||
|
"total_tracks": totalTracks,
|
||||||
|
"type": getString(release, "type"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,14 +1492,18 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
|||||||
albumName = getString(albumInfo, "name")
|
albumName = getString(albumInfo, "name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentRating := getMap(track, "contentRating")
|
||||||
|
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||||
|
|
||||||
trackResults := results["tracks"].([]map[string]interface{})
|
trackResults := results["tracks"].([]map[string]interface{})
|
||||||
trackResults = append(trackResults, map[string]interface{}{
|
trackResults = append(trackResults, map[string]interface{}{
|
||||||
"id": trackID,
|
"id": trackID,
|
||||||
"name": trackName,
|
"name": trackName,
|
||||||
"artists": trackArtistsString,
|
"artists": trackArtistsString,
|
||||||
"album": albumName,
|
"album": albumName,
|
||||||
"duration": durationString,
|
"duration": durationString,
|
||||||
"cover": cover,
|
"cover": cover,
|
||||||
|
"is_explicit": isExplicit,
|
||||||
})
|
})
|
||||||
results["tracks"] = trackResults
|
results["tracks"] = trackResults
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-72
@@ -47,6 +47,7 @@ type TrackMetadata struct {
|
|||||||
Publisher string `json:"publisher,omitempty"`
|
Publisher string `json:"publisher,omitempty"`
|
||||||
Plays string `json:"plays,omitempty"`
|
Plays string `json:"plays,omitempty"`
|
||||||
PreviewURL string `json:"preview_url,omitempty"`
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
|
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistSimple struct {
|
type ArtistSimple struct {
|
||||||
@@ -79,6 +80,7 @@ type AlbumTrackMetadata struct {
|
|||||||
Plays string `json:"plays,omitempty"`
|
Plays string `json:"plays,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
PreviewURL string `json:"preview_url,omitempty"`
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
|
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
@@ -198,6 +200,7 @@ type apiTrackResponse struct {
|
|||||||
Medium string `json:"medium"`
|
Medium string `json:"medium"`
|
||||||
Large string `json:"large"`
|
Large string `json:"large"`
|
||||||
} `json:"cover"`
|
} `json:"cover"`
|
||||||
|
IsExplicit bool `json:"is_explicit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAlbumResponse struct {
|
type apiAlbumResponse struct {
|
||||||
@@ -208,12 +211,13 @@ type apiAlbumResponse struct {
|
|||||||
ReleaseDate string `json:"releaseDate"`
|
ReleaseDate string `json:"releaseDate"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Tracks []struct {
|
Tracks []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
ArtistIds []string `json:"artistIds"`
|
ArtistIds []string `json:"artistIds"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
Plays string `json:"plays"`
|
Plays string `json:"plays"`
|
||||||
|
IsExplicit bool `json:"is_explicit"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +244,7 @@ type apiPlaylistResponse struct {
|
|||||||
AlbumArtist string `json:"albumArtist"`
|
AlbumArtist string `json:"albumArtist"`
|
||||||
AlbumID string `json:"albumId"`
|
AlbumID string `json:"albumId"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
|
IsExplicit bool `json:"is_explicit"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +266,13 @@ type apiArtistResponse struct {
|
|||||||
Gallery []string `json:"gallery"`
|
Gallery []string `json:"gallery"`
|
||||||
Discography struct {
|
Discography struct {
|
||||||
All []struct {
|
All []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Year int `json:"year"`
|
Year int `json:"year"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Type string `json:"type"`
|
||||||
} `json:"all"`
|
} `json:"all"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"discography"`
|
} `json:"discography"`
|
||||||
@@ -274,12 +281,13 @@ type apiArtistResponse struct {
|
|||||||
type apiSearchResponse struct {
|
type apiSearchResponse struct {
|
||||||
Results struct {
|
Results struct {
|
||||||
Tracks []struct {
|
Tracks []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Album string `json:"album"`
|
Album string `json:"album"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
|
IsExplicit bool `json:"is_explicit"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
Albums []struct {
|
Albums []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -320,6 +328,7 @@ type SearchResult struct {
|
|||||||
Duration int `json:"duration_ms,omitempty"`
|
Duration int `json:"duration_ms,omitempty"`
|
||||||
TotalTracks int `json:"total_tracks,omitempty"`
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchResponse struct {
|
type SearchResponse struct {
|
||||||
@@ -464,6 +473,10 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string)
|
|||||||
if err := client.Initialize(); err != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
}
|
}
|
||||||
|
return c.fetchAlbumWithClient(ctx, client, albumID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) {
|
||||||
|
|
||||||
allItems := []interface{}{}
|
allItems := []interface{}{}
|
||||||
offset := 0
|
offset := 0
|
||||||
@@ -727,6 +740,12 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset += limit
|
offset += limit
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
albumsItems := []interface{}{}
|
albumsItems := []interface{}{}
|
||||||
@@ -843,6 +862,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
Copyright: raw.Copyright,
|
Copyright: raw.Copyright,
|
||||||
Publisher: raw.Album.Label,
|
Publisher: raw.Album.Label,
|
||||||
Plays: raw.Plays,
|
Plays: raw.Plays,
|
||||||
|
IsExplicit: raw.IsExplicit,
|
||||||
}
|
}
|
||||||
|
|
||||||
return TrackResponse{
|
return TrackResponse{
|
||||||
@@ -904,6 +924,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
ArtistURL: artistURL,
|
ArtistURL: artistURL,
|
||||||
ArtistsData: artistsData,
|
ArtistsData: artistsData,
|
||||||
Plays: item.Plays,
|
Plays: item.Plays,
|
||||||
|
IsExplicit: item.IsExplicit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +985,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
ArtistsData: artistsData,
|
ArtistsData: artistsData,
|
||||||
Plays: item.Plays,
|
Plays: item.Plays,
|
||||||
Status: item.Status,
|
Status: item.Status,
|
||||||
|
IsExplicit: item.IsExplicit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,79 +1017,105 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All))
|
albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All))
|
||||||
allTracks := make([]AlbumTrackMetadata, 0)
|
allTracks := make([]AlbumTrackMetadata, 0)
|
||||||
|
|
||||||
|
type fetchResult struct {
|
||||||
|
tracks []AlbumTrackMetadata
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsChan := make(chan fetchResult, len(raw.Discography.All))
|
||||||
|
sem := make(chan struct{}, 5)
|
||||||
|
|
||||||
|
sharedClient := NewSpotifyClient()
|
||||||
|
if err := sharedClient.Initialize(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize shared spotify client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
for _, alb := range raw.Discography.All {
|
for _, alb := range raw.Discography.All {
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
|
|
||||||
return &ArtistDiscographyPayload{
|
|
||||||
ArtistInfo: info,
|
|
||||||
AlbumList: albumList,
|
|
||||||
TrackList: allTracks,
|
|
||||||
}, ctx.Err()
|
|
||||||
default:
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
albumList = append(albumList, DiscographyAlbumMetadata{
|
albumList = append(albumList, DiscographyAlbumMetadata{
|
||||||
ID: alb.ID,
|
ID: alb.ID,
|
||||||
Name: alb.Name,
|
Name: alb.Name,
|
||||||
AlbumType: "album",
|
AlbumType: alb.Type,
|
||||||
ReleaseDate: alb.Date,
|
ReleaseDate: alb.Date,
|
||||||
TotalTracks: 0,
|
TotalTracks: alb.TotalTracks,
|
||||||
Artists: raw.Name,
|
Artists: raw.Name,
|
||||||
Images: alb.Cover,
|
Images: alb.Cover,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||||
})
|
})
|
||||||
|
|
||||||
albumData, err := c.fetchAlbum(ctx, alb.ID)
|
go func(albumID string, albumName string) {
|
||||||
if err != nil {
|
sem <- struct{}{}
|
||||||
fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, tr := range albumData.Tracks {
|
time.Sleep(100 * time.Millisecond)
|
||||||
durationMS := parseDuration(tr.Duration)
|
defer func() { <-sem }()
|
||||||
trackNumber := idx + 1
|
|
||||||
|
|
||||||
var artistID, artistURL string
|
select {
|
||||||
if len(tr.ArtistIds) > 0 {
|
case <-ctx.Done():
|
||||||
artistID = tr.ArtistIds[0]
|
resultsChan <- fetchResult{err: ctx.Err()}
|
||||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID)
|
return
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds))
|
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID)
|
||||||
for _, id := range tr.ArtistIds {
|
if err != nil {
|
||||||
artistsData = append(artistsData, ArtistSimple{
|
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||||
ID: id,
|
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
||||||
Name: "",
|
return
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id),
|
}
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
|
||||||
|
for idx, tr := range albumData.Tracks {
|
||||||
|
durationMS := parseDuration(tr.Duration)
|
||||||
|
trackNumber := idx + 1
|
||||||
|
|
||||||
|
var artistID, artistURL string
|
||||||
|
if len(tr.ArtistIds) > 0 {
|
||||||
|
artistID = tr.ArtistIds[0]
|
||||||
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds))
|
||||||
|
for _, id := range tr.ArtistIds {
|
||||||
|
artistsData = append(artistsData, ArtistSimple{
|
||||||
|
ID: id,
|
||||||
|
Name: "",
|
||||||
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: tr.ID,
|
||||||
|
Artists: tr.Artists,
|
||||||
|
Name: tr.Name,
|
||||||
|
AlbumName: albumData.Name,
|
||||||
|
AlbumArtist: raw.Name,
|
||||||
|
AlbumType: "album",
|
||||||
|
DurationMS: durationMS,
|
||||||
|
Images: albumData.Cover,
|
||||||
|
ReleaseDate: albumData.ReleaseDate,
|
||||||
|
TrackNumber: trackNumber,
|
||||||
|
TotalTracks: albumData.Count,
|
||||||
|
DiscNumber: 1,
|
||||||
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
||||||
|
ISRC: tr.ID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID),
|
||||||
|
ArtistID: artistID,
|
||||||
|
ArtistURL: artistURL,
|
||||||
|
ArtistsData: artistsData,
|
||||||
|
Plays: tr.Plays,
|
||||||
|
IsExplicit: tr.IsExplicit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
resultsChan <- fetchResult{tracks: tracks}
|
||||||
|
}(alb.ID, alb.Name)
|
||||||
|
}
|
||||||
|
|
||||||
allTracks = append(allTracks, AlbumTrackMetadata{
|
for i := 0; i < len(raw.Discography.All); i++ {
|
||||||
SpotifyID: tr.ID,
|
res := <-resultsChan
|
||||||
Artists: tr.Artists,
|
if res.err != nil {
|
||||||
Name: tr.Name,
|
return nil, res.err
|
||||||
AlbumName: albumData.Name,
|
|
||||||
AlbumArtist: albumData.Artists,
|
|
||||||
AlbumType: "album",
|
|
||||||
DurationMS: durationMS,
|
|
||||||
Images: albumData.Cover,
|
|
||||||
ReleaseDate: albumData.ReleaseDate,
|
|
||||||
TrackNumber: trackNumber,
|
|
||||||
TotalTracks: albumData.Count,
|
|
||||||
DiscNumber: 1,
|
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
|
||||||
ISRC: tr.ID,
|
|
||||||
AlbumID: alb.ID,
|
|
||||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
|
||||||
ArtistID: artistID,
|
|
||||||
ArtistURL: artistURL,
|
|
||||||
ArtistsData: artistsData,
|
|
||||||
Plays: tr.Plays,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
allTracks = append(allTracks, res.tracks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ArtistDiscographyPayload{
|
return &ArtistDiscographyPayload{
|
||||||
@@ -1246,6 +1294,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
|||||||
Images: item.Cover,
|
Images: item.Cover,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
Duration: parseDuration(item.Duration),
|
Duration: parseDuration(item.Duration),
|
||||||
|
IsExplicit: item.IsExplicit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1359,6 +1408,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
|||||||
Images: item.Cover,
|
Images: item.Cover,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
Duration: parseDuration(item.Duration),
|
Duration: parseDuration(item.Duration),
|
||||||
|
IsExplicit: item.IsExplicit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "album":
|
case "album":
|
||||||
|
|||||||
+99
-256
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,38 +18,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TidalDownloader struct {
|
type TidalDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
maxRetries int
|
maxRetries int
|
||||||
clientID string
|
apiURL string
|
||||||
clientSecret string
|
|
||||||
apiURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TidalTrack struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
AudioQuality string `json:"audioQuality"`
|
|
||||||
TrackNumber int `json:"trackNumber"`
|
|
||||||
VolumeNumber int `json:"volumeNumber"`
|
|
||||||
Duration int `json:"duration"`
|
|
||||||
Copyright string `json:"copyright"`
|
|
||||||
Explicit bool `json:"explicit"`
|
|
||||||
Album struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Cover string `json:"cover"`
|
|
||||||
ReleaseDate string `json:"releaseDate"`
|
|
||||||
} `json:"album"`
|
|
||||||
Artists []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"artists"`
|
|
||||||
Artist struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"artist"`
|
|
||||||
MediaMetadata struct {
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
} `json:"mediaMetadata"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TidalAPIResponse struct {
|
type TidalAPIResponse struct {
|
||||||
@@ -70,11 +43,6 @@ type TidalAPIResponseV2 struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TidalAPIInfo struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TidalBTSManifest struct {
|
type TidalBTSManifest struct {
|
||||||
MimeType string `json:"mimeType"`
|
MimeType string `json:"mimeType"`
|
||||||
Codecs string `json:"codecs"`
|
Codecs string `json:"codecs"`
|
||||||
@@ -83,19 +51,14 @@ type TidalBTSManifest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
|
||||||
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
|
||||||
|
|
||||||
if apiURL == "" {
|
if apiURL == "" {
|
||||||
downloader := &TidalDownloader{
|
downloader := &TidalDownloader{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
timeout: 5 * time.Second,
|
timeout: 5 * time.Second,
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
clientID: string(clientID),
|
apiURL: "",
|
||||||
clientSecret: string(clientSecret),
|
|
||||||
apiURL: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apis, err := downloader.GetAvailableAPIs()
|
apis, err := downloader.GetAvailableAPIs()
|
||||||
@@ -108,79 +71,30 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
timeout: 5 * time.Second,
|
timeout: 5 * time.Second,
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
clientID: string(clientID),
|
apiURL: apiURL,
|
||||||
clientSecret: string(clientSecret),
|
|
||||||
apiURL: apiURL,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
|
apis := []string{
|
||||||
encodedAPIs := []string{
|
"https://triton.squid.wtf",
|
||||||
"dm9nZWwucXFkbC5zaXRl",
|
"https://hifi-one.spotisaver.net",
|
||||||
"bWF1cy5xcWRsLnNpdGU=",
|
"https://hifi-two.spotisaver.net",
|
||||||
"aHVuZC5xcWRsLnNpdGU=",
|
"https://tidal.kinoplus.online",
|
||||||
"a2F0emUucXFkbC5zaXRl",
|
"https://tidal-api.binimum.org",
|
||||||
"d29sZi5xcWRsLnNpdGU=",
|
|
||||||
"dGlkYWwua2lub3BsdXMub25saW5l",
|
|
||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
|
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
|
||||||
for _, encoded := range encodedAPIs {
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
apis = append(apis, "https://"+string(decoded))
|
|
||||||
}
|
|
||||||
|
|
||||||
return apis, nil
|
return apis, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|
||||||
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
|
|
||||||
|
|
||||||
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
|
||||||
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.SetBasicAuth(t.clientID, t.clientSecret)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.AccessToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase := "https://open.spotify.com/track/"
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -237,42 +151,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|||||||
return trackID, nil
|
return trackID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
|
||||||
token, err := t.GetAccessToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=")
|
|
||||||
trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", trackURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("failed to get track info: HTTP %d - %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var trackInfo TidalTrack
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found: %s (%s)\n", trackInfo.Title, trackInfo.AudioQuality)
|
|
||||||
return &trackInfo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
fmt.Println("Fetching URL...")
|
fmt.Println("Fetching URL...")
|
||||||
|
|
||||||
@@ -330,25 +208,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("download URL not found in response")
|
return "", fmt.Errorf("download URL not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
|
|
||||||
albumID = strings.ReplaceAll(albumID, "-", "/")
|
|
||||||
|
|
||||||
imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=")
|
|
||||||
artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID)
|
|
||||||
|
|
||||||
resp, err := t.client.Get(artURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("failed to download album art: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return io.ReadAll(resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||||
|
|
||||||
if strings.HasPrefix(url, "MANIFEST:") {
|
if strings.HasPrefix(url, "MANIFEST:") {
|
||||||
@@ -528,7 +387,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("directory error: %w", err)
|
return "", fmt.Errorf("directory error: %w", err)
|
||||||
@@ -542,12 +401,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
trackInfo, err := t.GetTrackInfoByID(trackID)
|
if trackID == 0 {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackInfo.ID == 0 {
|
|
||||||
return "", fmt.Errorf("no track ID found")
|
return "", fmt.Errorf("no track ID found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +414,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, 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 {
|
||||||
@@ -568,9 +422,17 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
return "EXISTS:" + outputFilename, nil
|
return "EXISTS:" + outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality)
|
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
if quality == "HI_RES" && allowFallback {
|
||||||
|
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
|
||||||
|
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
@@ -626,7 +488,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
return outputFilename, nil
|
return outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
||||||
apis, err := t.GetAvailableAPIs()
|
apis, err := t.GetAvailableAPIs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||||
@@ -645,12 +507,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
trackInfo, err := t.GetTrackInfoByID(trackID)
|
if trackID == 0 {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackInfo.ID == 0 {
|
|
||||||
return "", fmt.Errorf("no track ID found")
|
return "", fmt.Errorf("no track ID found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +520,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, 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 {
|
||||||
@@ -671,9 +528,17 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
return "EXISTS:" + outputFilename, nil
|
return "EXISTS:" + outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality)
|
successAPI, downloadURL, err := getDownloadURLRotated(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
if quality == "HI_RES" && allowFallback {
|
||||||
|
fmt.Println("⚠ HI_RES unavailable/failed on all APIs, falling back to LOSSLESS...")
|
||||||
|
successAPI, downloadURL, err = getDownloadURLRotated(apis, trackID, "LOSSLESS")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
@@ -730,14 +595,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
return outputFilename, nil
|
return outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
||||||
|
|
||||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SegmentTemplate struct {
|
type SegmentTemplate struct {
|
||||||
@@ -902,89 +767,67 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return "", initURL, mediaURLs, nil
|
return "", initURL, mediaURLs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type manifestResult struct {
|
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
|
||||||
apiURL string
|
|
||||||
manifest string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", "", fmt.Errorf("no APIs available")
|
return "", "", fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
resultChan := make(chan manifestResult, len(apis))
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
rand.Shuffle(len(apis), func(i, j int) { apis[i], apis[j] = apis[j], apis[i] })
|
||||||
|
|
||||||
fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis))
|
fmt.Printf("Rotating through %d APIs...\n", len(apis))
|
||||||
for _, apiURL := range apis {
|
|
||||||
go func(api string) {
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- manifestResult{apiURL: api, err: err}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- manifestResult{apiURL: api, err: err}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var v2Response TidalAPIResponseV2
|
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
|
||||||
resultChan <- manifestResult{apiURL: api, manifest: v2Response.Data.Manifest, err: nil}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var v1Responses []TidalAPIResponse
|
|
||||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
|
||||||
for _, item := range v1Responses {
|
|
||||||
if item.OriginalTrackURL != "" {
|
|
||||||
|
|
||||||
resultChan <- manifestResult{apiURL: api, manifest: "DIRECT:" + item.OriginalTrackURL, err: nil}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response")}
|
|
||||||
}(apiURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastError error
|
var lastError error
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
for i := 0; i < len(apis); i++ {
|
for _, apiURL := range apis {
|
||||||
result := <-resultChan
|
fmt.Printf("Trying API: %s\n", apiURL)
|
||||||
if result.err == nil && result.manifest != "" {
|
|
||||||
|
|
||||||
fmt.Printf("✓ Got response from: %s\n", result.apiURL)
|
client := &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
if strings.HasPrefix(result.manifest, "DIRECT:") {
|
|
||||||
return result.apiURL, strings.TrimPrefix(result.manifest, "DIRECT:"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.apiURL, "MANIFEST:" + result.manifest, nil
|
|
||||||
} else {
|
|
||||||
errMsg := result.err.Error()
|
|
||||||
if len(errMsg) > 50 {
|
|
||||||
errMsg = errMsg[:50] + "..."
|
|
||||||
}
|
|
||||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
|
||||||
lastError = result.err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
lastError = err
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
resp.Body.Close()
|
||||||
|
lastError = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
lastError = err
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var v2Response TidalAPIResponseV2
|
||||||
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
|
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||||
|
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var v1Responses []TidalAPIResponse
|
||||||
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||||
|
for _, item := range v1Responses {
|
||||||
|
if item.OriginalTrackURL != "" {
|
||||||
|
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||||
|
return apiURL, item.OriginalTrackURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = fmt.Errorf("no download URL or manifest in response")
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("All APIs failed:")
|
fmt.Println("All APIs failed:")
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendNowResponse []struct {
|
||||||
|
FileCode string `json:"file_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadToSendNow(filePath string) (string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
return uploadToService(filepath.Base(filePath), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadBytesToSendNow(filename string, data []byte) (string, error) {
|
||||||
|
return uploadToService(filename, bytes.NewReader(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadToService(filename string, fileReader io.Reader) (string, error) {
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
fields := map[string]string{
|
||||||
|
"sess_id": "",
|
||||||
|
"utype": "anon",
|
||||||
|
"hidden": "",
|
||||||
|
"enableemail": "",
|
||||||
|
"link_rcpt": "",
|
||||||
|
"link_pass": "",
|
||||||
|
"file_expire_time": "",
|
||||||
|
"file_expire_unit": "DAY",
|
||||||
|
"file_max_dl": "1",
|
||||||
|
"file_public": "1",
|
||||||
|
"keepalive": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, val := range fields {
|
||||||
|
if err := writer.WriteField(key, val); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("file_0", filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, fileReader); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://u1112.send.now/cgi-bin/upload.cgi?upload_type=file&utype=anon", body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("Origin", "https://send.now")
|
||||||
|
req.Header.Set("Referer", "https://send.now/")
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("upload failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
respBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("server error %d: %s", resp.StatusCode, string(respBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result SendNowResponse
|
||||||
|
if err := json.Unmarshal(respBytes, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %v, raw: %s", err, string(respBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 || result[0].FileCode == "" {
|
||||||
|
return "", fmt.Errorf("invalid response format")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCode := result[0].FileCode
|
||||||
|
downloadLink := fmt.Sprintf("https://send.now/%s", fileCode)
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
if ext == ".mp4" || ext == ".mov" || ext == ".mkv" || ext == ".webm" || ext == ".avi" {
|
||||||
|
return fmt.Sprintf("[Video](%s)", downloadLink), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchDirectImageLink(downloadLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchDirectImageLink(url string) (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
htmlBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
htmlStr := string(htmlBytes)
|
||||||
|
|
||||||
|
reFullRes := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["'][^>]*title=["']Open image on new tab["']`)
|
||||||
|
matchesFull := reFullRes.FindStringSubmatch(htmlStr)
|
||||||
|
if len(matchesFull) > 1 {
|
||||||
|
return fmt.Sprintf("", matchesFull[1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reClipboard := regexp.MustCompile(`(?s)data-clipboard-text=['"]<a href="[^"]+".*?><img src="([^"]+)"`)
|
||||||
|
matches := reClipboard.FindStringSubmatch(htmlStr)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return fmt.Sprintf("", matches[1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reImg := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']*?\.send\.now/i/[^"']+)["']`)
|
||||||
|
matchesImg := reImg.FindStringSubmatch(htmlStr)
|
||||||
|
if len(matchesImg) > 1 {
|
||||||
|
return fmt.Sprintf("", matchesImg[1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reAnchor := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
||||||
|
matchesAnchor := reAnchor.FindStringSubmatch(htmlStr)
|
||||||
|
if len(matchesAnchor) > 1 {
|
||||||
|
return fmt.Sprintf("", matchesAnchor[1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reGeneric := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
||||||
|
matchesGeneric := reGeneric.FindAllStringSubmatch(htmlStr, -1)
|
||||||
|
for _, match := range matchesGeneric {
|
||||||
|
if len(match) > 1 {
|
||||||
|
link := match[1]
|
||||||
|
|
||||||
|
if !regexp.MustCompile(`(?i)(logo|icon|button|assets)`).MatchString(filepath.Base(link)) {
|
||||||
|
return fmt.Sprintf("", link), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("[View File](%s)", url), nil
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
42597f825aff483763c8cb00c83bfa74
|
629a5f17426ea4202a25837a341483dd
|
||||||
Generated
+33
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@radix-ui/react-progress':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.8
|
specifier: ^1.1.8
|
||||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-scroll-area':
|
||||||
|
specifier: ^1.2.10
|
||||||
|
version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@radix-ui/react-select':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.2.6
|
specifier: ^2.2.6
|
||||||
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -855,6 +858,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-scroll-area@1.2.10':
|
||||||
|
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-select@2.2.6':
|
'@radix-ui/react-select@2.2.6':
|
||||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2725,6 +2741,23 @@ snapshots:
|
|||||||
'@types/react': 19.2.8
|
'@types/react': 19.2.8
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||||
|
|
||||||
|
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.1
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.8
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||||
|
|
||||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.1
|
'@radix-ui/number': 1.1.1
|
||||||
|
|||||||
+21
-49
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Search, X, ArrowUp } from "lucide-react";
|
import { Search, X, ArrowUp } from "lucide-react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||||
@@ -48,14 +46,18 @@ function App() {
|
|||||||
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 [isSearchMode, setIsSearchMode] = useState(false);
|
||||||
|
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("spotiflac_region", region);
|
||||||
|
}, [region]);
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||||
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
|
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
|
||||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
||||||
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "7.0.6";
|
const CURRENT_VERSION = __APP_VERSION__;
|
||||||
const download = useDownload();
|
const download = useDownload(region);
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
const lyrics = useLyrics();
|
const lyrics = useLyrics();
|
||||||
const cover = useCover();
|
const cover = useCover();
|
||||||
@@ -293,11 +295,14 @@ function App() {
|
|||||||
};
|
};
|
||||||
const toggleSelectAll = (tracks: any[]) => {
|
const toggleSelectAll = (tracks: any[]) => {
|
||||||
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
|
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
|
||||||
if (selectedTracks.length === tracksWithIsrc.length) {
|
if (tracksWithIsrc.length === 0)
|
||||||
setSelectedTracks([]);
|
return;
|
||||||
|
const allSelected = tracksWithIsrc.every(isrc => selectedTracks.includes(isrc));
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedTracks(prev => prev.filter(isrc => !tracksWithIsrc.includes(isrc)));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
setSelectedTracks(tracksWithIsrc);
|
setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithIsrc])));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleOpenFolder = async () => {
|
const handleOpenFolder = async () => {
|
||||||
@@ -319,11 +324,11 @@ function App() {
|
|||||||
return null;
|
return null;
|
||||||
if ("track" in metadata.metadata) {
|
if ("track" in metadata.metadata) {
|
||||||
const { track } = metadata.metadata;
|
const { track } = metadata.metadata;
|
||||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>);
|
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
||||||
}
|
}
|
||||||
if ("album_info" in metadata.metadata) {
|
if ("album_info" in metadata.metadata) {
|
||||||
const { album_info, track_list } = metadata.metadata;
|
const { album_info, track_list } = metadata.metadata;
|
||||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => {
|
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -337,7 +342,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
if ("playlist_info" in metadata.metadata) {
|
if ("playlist_info" in metadata.metadata) {
|
||||||
const { playlist_info, track_list } = metadata.metadata;
|
const { playlist_info, track_list } = metadata.metadata;
|
||||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -351,7 +356,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
if ("artist_info" in metadata.metadata) {
|
if ("artist_info" in metadata.metadata) {
|
||||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -400,7 +405,10 @@ function App() {
|
|||||||
case "about":
|
case "about":
|
||||||
return <AboutPage version={CURRENT_VERSION}/>;
|
return <AboutPage version={CURRENT_VERSION}/>;
|
||||||
case "history":
|
case "history":
|
||||||
return <HistoryPage />;
|
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||||
|
metadata.loadFromCache(cachedData);
|
||||||
|
setCurrentPage("main");
|
||||||
|
}}/>;
|
||||||
case "audio-analysis":
|
case "audio-analysis":
|
||||||
return <AudioAnalysisPage />;
|
return <AudioAnalysisPage />;
|
||||||
case "audio-converter":
|
case "audio-converter":
|
||||||
@@ -412,42 +420,6 @@ function App() {
|
|||||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
|
|
||||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
|
||||||
<div className="absolute right-4 top-4">
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
|
||||||
<X className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
|
||||||
with large discography.
|
|
||||||
</DialogDescription>
|
|
||||||
{metadata.pendingArtistName && (<div className="py-2">
|
|
||||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
|
||||||
</div>)}
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
|
||||||
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
|
||||||
minutes).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={metadata.handleConfirmFetch}>
|
|
||||||
<Search className="h-4 w-4"/>
|
|
||||||
Fetch
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
@@ -487,7 +459,7 @@ function App() {
|
|||||||
if (updatedUrl) {
|
if (updatedUrl) {
|
||||||
setSpotifyUrl(updatedUrl);
|
setSpotifyUrl(updatedUrl);
|
||||||
}
|
}
|
||||||
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/>
|
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} region={region} onRegionChange={setRegion}/>
|
||||||
|
|
||||||
{!isSearchMode && metadata.metadata && renderMetadata()}
|
{!isSearchMode && metadata.metadata && renderMetadata()}
|
||||||
</>);
|
</>);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.8.3, SVG Export Plug-In . SVG Version: 2.1.1 Build 3) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #733e0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: #fdc700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: #1ed760;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="st2" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0v.1ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1v.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
|
||||||
|
<path class="st1" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1h0Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M70.76,465.35v-77.71h23.4l50.61,59.75-5.66,1.31v-61.06h18.83v77.71h-23.51l-49.52-58.45,4.57-1.74v60.19h-18.72Z"/>
|
||||||
|
<path class="st0" d="M171.65,465.35v-77.71h76.51v15.78h-55.73v15.24h51.48v15.13h-51.48v15.78h55.73v15.78h-76.51Z"/>
|
||||||
|
<path class="st0" d="M254.8,465.35l41.47-45.17-2.39,9.25-37.33-41.79h26.34l28.08,32.65-13.17-.44,29.17-32.22h23.51l-39.07,42.01.65-8.82,39.72,44.51h-26.23l-29.82-34.72,14.26-.65-31.56,35.37h-23.62Z"/>
|
||||||
|
<path class="st0" d="M387.8,465.35v-62.04h-32.76v-15.67h86.2v15.67h-32.65v62.04h-20.79Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -3,28 +3,30 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { GetOSInfo } from "../../wailsjs/go/main/App";
|
import { GetOSInfo } from "../../wailsjs/go/main/App";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
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 { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download } from "lucide-react";
|
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks } from "lucide-react";
|
||||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
import XProIcon from "@/assets/x-pro.webp";
|
import XProIcon from "@/assets/x-pro.webp";
|
||||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
|
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||||
import { langColors } from "@/assets/github-lang-colors";
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { DragDropMedia } from "./DragDropTextarea";
|
||||||
interface AboutPageProps {
|
interface AboutPageProps {
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
export function AboutPage({ version }: AboutPageProps) {
|
export function AboutPage({ version }: AboutPageProps) {
|
||||||
const [os, setOs] = useState("Unknown");
|
const [os, setOs] = useState("Unknown");
|
||||||
const [location, setLocation] = useState("Unknown");
|
const [location, setLocation] = useState("Unknown");
|
||||||
const [reportType, setReportType] = useState("bug");
|
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects">("bug_report");
|
||||||
|
const [bugType, setBugType] = useState("Track");
|
||||||
const [problem, setProblem] = useState("");
|
const [problem, setProblem] = useState("");
|
||||||
const [bugType, setBugType] = useState<string>("Track");
|
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
const [bugContext, setBugContext] = useState("");
|
const [bugContext, setBugContext] = useState("");
|
||||||
const [featureDesc, setFeatureDesc] = useState("");
|
const [featureDesc, setFeatureDesc] = useState("");
|
||||||
@@ -88,6 +90,7 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
}
|
}
|
||||||
const repos = [
|
const repos = [
|
||||||
{ name: 'SpotiDownloader', owner: 'afkarxyz' },
|
{ name: 'SpotiDownloader', owner: 'afkarxyz' },
|
||||||
|
{ name: 'SpotiFLAC-Next', owner: 'spotiverse' },
|
||||||
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' }
|
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' }
|
||||||
];
|
];
|
||||||
const stats: Record<string, any> = {};
|
const stats: Record<string, any> = {};
|
||||||
@@ -167,9 +170,6 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source."
|
a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const sanitizeForURL = (text: string): string => {
|
|
||||||
return text.replace(/[()]/g, "").replace(/,/g, " -");
|
|
||||||
};
|
|
||||||
const formatTimeAgo = (dateString: string): string => {
|
const formatTimeAgo = (dateString: string): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updated = new Date(dateString);
|
const updated = new Date(dateString);
|
||||||
@@ -199,210 +199,240 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
return langColors[lang] || '#858585';
|
return langColors[lang] || '#858585';
|
||||||
};
|
};
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
let title = "";
|
const title = activeTab === "bug_report"
|
||||||
let body = "";
|
? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}`
|
||||||
if (reportType === "bug") {
|
: `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
|
||||||
title = `[Bug Report] ${problem ? problem.substring(0, 50) + (problem.length > 50 ? "..." : "") : "Issue"}`;
|
let bodyContent = "";
|
||||||
body = `### [Bug Report]
|
if (activeTab === "bug_report") {
|
||||||
|
const contextContent = bugContext.trim() ? bugContext.trim() : "Type here or send screenshot/recording";
|
||||||
|
bodyContent = `### [Bug Report]
|
||||||
|
|
||||||
#### Problem
|
#### Problem
|
||||||
> ${problem || "Type here"}
|
${problem || "Type here"}
|
||||||
|
|
||||||
#### Type
|
#### Type
|
||||||
${bugType || "Track / Album / Playlist / Artist"}
|
${bugType}
|
||||||
|
|
||||||
#### Spotify URL
|
#### Spotify URL
|
||||||
> ${spotifyUrl || "Type here"}
|
${spotifyUrl || "Type here"}
|
||||||
|
|
||||||
#### Additional Context
|
#### Additional Context
|
||||||
> ${bugContext || "Type here or send screenshot/recording"}
|
${contextContent}
|
||||||
|
|
||||||
#### Version
|
#### Environment
|
||||||
SpotiFLAC v${version}
|
- SpotiFLAC Version: ${version}
|
||||||
|
- OS: ${os}
|
||||||
#### OS
|
- Location: ${location}`;
|
||||||
${sanitizeForURL(os || "Unknown")}
|
|
||||||
|
|
||||||
#### Location
|
|
||||||
${location || "Unknown"}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
title = `[Feature Request] ${featureDesc ? featureDesc.substring(0, 50) + (featureDesc.length > 50 ? "..." : "") : "Request"}`;
|
const contextContent = featureContext.trim() ? featureContext.trim() : "Type here or send screenshot/recording";
|
||||||
body = `### [Feature Request]
|
bodyContent = `### [Feature Request]
|
||||||
|
|
||||||
#### Description
|
#### Description
|
||||||
> ${featureDesc || "Type here"}
|
${featureDesc || "Type here"}
|
||||||
|
|
||||||
#### Use Case
|
#### Use Case
|
||||||
> ${useCase || "Type here"}
|
${useCase || "Type here"}
|
||||||
|
|
||||||
#### Additional Context
|
#### Additional Context
|
||||||
> ${featureContext || "Type here or send screenshot/recording"}
|
${contextContent}`;
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
|
const params = new URLSearchParams({
|
||||||
|
title: title,
|
||||||
|
body: bodyContent
|
||||||
|
});
|
||||||
|
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
|
||||||
openExternal(url);
|
openExternal(url);
|
||||||
};
|
};
|
||||||
return (<div className="animate-in slide-in-from-bottom-12 fade-in duration-500 ease-out space-y-6">
|
return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
|
||||||
<div>
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="report" className="w-full">
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
<TabsList className="grid w-full grid-cols-3 cursor-pointer">
|
<Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
|
||||||
<TabsTrigger value="report" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Report Issue</TabsTrigger>
|
<Bug className="h-4 w-4"/>
|
||||||
<TabsTrigger value="faq" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">FAQ</TabsTrigger>
|
Bug Report
|
||||||
<TabsTrigger value="projects" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Other Projects</TabsTrigger>
|
</Button>
|
||||||
</TabsList>
|
<Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none">
|
||||||
|
<Lightbulb className="h-4 w-4"/>
|
||||||
|
Feature Request
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none">
|
||||||
|
<CircleHelp className="h-4 w-4"/>
|
||||||
|
FAQ
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
||||||
|
<Blocks className="h-4 w-4"/>
|
||||||
|
Other Projects
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="report" className="mt-4">
|
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
|
||||||
<Card>
|
{activeTab === "bug_report" && (<div className="flex flex-col">
|
||||||
<CardContent className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4 flex flex-col">
|
||||||
<Tabs value={reportType} onValueChange={setReportType} className="w-full">
|
<div className="mt-4 pr-2">
|
||||||
<TabsList className="w-full grid grid-cols-2 cursor-pointer pb-2">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
<TabsTrigger value="bug" className="flex items-center gap-2 cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"><Bug className="h-4 w-4" /> Bug Report</TabsTrigger>
|
|
||||||
<TabsTrigger value="feature" className="flex items-center gap-2 cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"><Lightbulb className="h-4 w-4" /> Feature Request</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
{reportType === "bug" ? (<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<Label>Problem</Label>
|
<Label>Problem</Label>
|
||||||
<Textarea className="flex-1 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)} />
|
<Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-2 flex flex-col">
|
||||||
|
<Label>Additional Context</Label>
|
||||||
|
<DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 flex flex-col">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
<ToggleGroup type="single" value={bugType} onValueChange={(val) => {
|
<ToggleGroup type="single" value={bugType} onValueChange={(val) => {
|
||||||
if (val)
|
if (val)
|
||||||
setBugType(val);
|
setBugType(val);
|
||||||
}} className="justify-start w-full cursor-pointer">
|
}} className="justify-start w-full cursor-pointer">
|
||||||
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">
|
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">Track</ToggleGroupItem>
|
||||||
Track
|
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">Album</ToggleGroupItem>
|
||||||
</ToggleGroupItem>
|
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">Playlist</ToggleGroupItem>
|
||||||
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">
|
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">Artist</ToggleGroupItem>
|
||||||
Album
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">
|
|
||||||
Playlist
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">
|
|
||||||
Artist
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Spotify URL</Label>
|
<Label>Spotify URL</Label>
|
||||||
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)} />
|
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)}/>
|
||||||
</div>
|
|
||||||
<div className="space-y-2 h-full">
|
|
||||||
<Label>Additional Context</Label>
|
|
||||||
<Textarea className="h-[125px] resize-none" placeholder="Any other details? Screenshots or recordings are very helpful (please upload directly to GitHub)." value={bugContext} onChange={e => setBugContext(e.target.value)} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>) : (<div className="grid md:grid-cols-2 gap-6">
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center pt-4 shrink-0">
|
||||||
|
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
||||||
|
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "feature_request" && (<div className="flex flex-col">
|
||||||
|
<div className="space-y-4 pt-4 flex flex-col">
|
||||||
|
<div className="mt-4 pr-2">
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<Label>Description</Label>
|
<Label>Description</Label>
|
||||||
<Textarea className="flex-1 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)} />
|
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-2 flex-col">
|
||||||
<div className="space-y-2">
|
<Label>Use Case</Label>
|
||||||
<Label>Use Case</Label>
|
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)}/>
|
||||||
<Textarea className="h-[100px] resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Additional Context</Label>
|
|
||||||
<Textarea className="h-[135px] resize-none" placeholder="Any other details? Screenshots/recordings or examples..." value={featureContext} onChange={e => setFeatureContext(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
<div className="space-y-2 flex-col">
|
||||||
|
<Label>Additional Context</Label>
|
||||||
|
<DragDropMedia className="min-h-[14rem]" value={featureContext} onChange={setFeatureContext}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</div>
|
||||||
|
<div className="flex justify-center pt-4 shrink-0">
|
||||||
|
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
||||||
|
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
<div className="flex justify-center pt-2">
|
{activeTab === "faq" && (<ScrollArea className="h-full">
|
||||||
<Button className="w-[200px] cursor-pointer" onClick={handleSubmit}>
|
<div className="p-1 pr-4">
|
||||||
<ExternalLink className="h-4 w-4" /> Create Issue on GitHub
|
<Card>
|
||||||
</Button>
|
<CardHeader>
|
||||||
|
<CardTitle>Frequently Asked Questions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
|
||||||
|
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
|
||||||
|
</div>))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>)}
|
||||||
|
|
||||||
|
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||||
|
<div className="grid gap-2 grid-cols-4">
|
||||||
|
<div className="flex flex-col gap-2 h-full">
|
||||||
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||||
|
<CardDescription className="flex gap-3 pt-2">
|
||||||
|
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
||||||
|
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
|
||||||
|
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/> SpotubeDL</CardTitle>
|
||||||
|
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader"/> SpotiDownloader</CardTitle>
|
||||||
|
<CardDescription>Get Spotify tracks in MP3 and FLAC via spotidownloader.com</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
|
||||||
|
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiDownloader'].forks}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
|
</Card>
|
||||||
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/> SpotiFLAC Next</CardTitle>
|
||||||
|
<CardDescription>Get Spotify tracks in Hi-Res lossless FLACs — no account required.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats['SpotiFLAC-Next'] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats['SpotiFLAC-Next'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiFLAC-Next'].stars)}</span>
|
||||||
|
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiFLAC-Next'].forks}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiFLAC-Next'].createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiFLAC-Next'].totalDownloads)}</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiFLAC-Next'].latestDownloads)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
|
</Card>
|
||||||
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader"/> Twitter/X Media Batch Downloader</CardTitle>
|
||||||
|
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
|
||||||
|
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>)}
|
||||||
</Card>
|
</div>
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="faq" className="mt-4 space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Frequently Asked Questions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
|
|
||||||
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
|
|
||||||
</div>))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="projects" className="mt-4 space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://exyezed.cc/")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
|
||||||
<CardDescription className="flex gap-3 pt-2">
|
|
||||||
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro" />
|
|
||||||
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS" />
|
|
||||||
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro" />
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://spotubedl.com/")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL" /> SpotubeDL</CardTitle>
|
|
||||||
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader" /> SpotiDownloader</CardTitle>
|
|
||||||
<CardDescription>Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500" /> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5" /> {repoStats['SpotiDownloader'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5" /> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5" /> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader" /> Twitter/X Media Batch Downloader</CardTitle>
|
|
||||||
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500" /> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5" /> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5" /> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5" /> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, ImageDown, FileText } from "lucide-react";
|
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
@@ -67,10 +67,16 @@ interface AlbumInfoProps {
|
|||||||
external_urls: string;
|
external_urls: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, }: AlbumInfoProps) {
|
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<Card>
|
<Card className="relative">
|
||||||
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<XCircle className="h-5 w-5"/>
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, ImageDown, FileText, BadgeCheck } from "lucide-react";
|
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck, XCircle, Filter } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
@@ -10,7 +10,10 @@ import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
|||||||
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
|
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
interface ArtistInfoProps {
|
interface ArtistInfoProps {
|
||||||
artistInfo: {
|
artistInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -31,6 +34,7 @@ interface ArtistInfoProps {
|
|||||||
release_date: string;
|
release_date: string;
|
||||||
album_type: string;
|
album_type: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
|
total_tracks?: number;
|
||||||
}>;
|
}>;
|
||||||
trackList: TrackMetadata[];
|
trackList: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -87,12 +91,40 @@ interface ArtistInfoProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, }: ArtistInfoProps) {
|
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||||
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||||
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
|
||||||
|
const filteredAlbumGroups = useMemo(() => {
|
||||||
|
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
|
||||||
|
const albumGroups = trackList.reduce((acc, track) => {
|
||||||
|
if (!track.album_name)
|
||||||
|
return acc;
|
||||||
|
if (!acc[track.album_name]) {
|
||||||
|
acc[track.album_name] = {
|
||||||
|
count: 0,
|
||||||
|
tracks: [],
|
||||||
|
type: albumTypeMap.get(track.album_name) || "unknown"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
acc[track.album_name].count++;
|
||||||
|
acc[track.album_name].tracks.push(track);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, {
|
||||||
|
count: number;
|
||||||
|
tracks: TrackMetadata[];
|
||||||
|
type: string;
|
||||||
|
}>);
|
||||||
|
return Object.entries(albumGroups).sort((a, b) => {
|
||||||
|
const dateA = a[1].tracks[0]?.release_date || "";
|
||||||
|
const dateB = b[1].tracks[0]?.release_date || "";
|
||||||
|
return dateB.localeCompare(dateA);
|
||||||
|
});
|
||||||
|
}, [trackList, albumList]);
|
||||||
const handleDownloadHeader = async () => {
|
const handleDownloadHeader = async () => {
|
||||||
if (!artistInfo.header)
|
if (!artistInfo.header)
|
||||||
return;
|
return;
|
||||||
@@ -238,13 +270,19 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
setDownloadingAllGallery(false);
|
setDownloadingAllGallery(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const hasGallery = artistInfo.gallery && artistInfo.gallery.length > 0;
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0 relative">
|
||||||
{artistInfo.header ? (<>
|
{artistInfo.header ? (<>
|
||||||
<div className="relative w-full h-64 bg-cover bg-center">
|
<div className="relative w-full h-64 bg-cover bg-center">
|
||||||
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
|
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
|
||||||
<div className="absolute top-4 right-4 z-10">
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
|
||||||
|
<XCircle className="h-5 w-5"/>
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
<div className="absolute bottom-4 right-4 z-10">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
|
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||||
@@ -277,20 +315,21 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<p className="text-sm font-medium text-white/80">Artist</p>
|
<p className="text-sm font-medium text-white/80">Artist</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
||||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-400 shrink-0"/>)}
|
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
|
||||||
</div>
|
</div>
|
||||||
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
|
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
{artistInfo.rank && (<>
|
||||||
|
<span>#{artistInfo.rank} rank</span>
|
||||||
|
<span>•</span>
|
||||||
|
</>)}
|
||||||
|
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
|
||||||
{artistInfo.listeners && (<>
|
{artistInfo.listeners && (<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
|
||||||
</>)}
|
</>)}
|
||||||
{artistInfo.rank && (<>
|
</div>
|
||||||
<span>•</span>
|
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||||
<span>#{artistInfo.rank} rank</span>
|
|
||||||
</>)}
|
|
||||||
<span>•</span>
|
|
||||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||||
@@ -304,6 +343,11 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>) : (<CardContent className="px-6 py-6">
|
</>) : (<CardContent className="px-6 py-6">
|
||||||
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<XCircle className="h-5 w-5"/>
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{artistInfo.images && (<div className="relative group">
|
{artistInfo.images && (<div className="relative group">
|
||||||
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
|
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
|
||||||
@@ -324,23 +368,24 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<p className="text-sm font-medium">Artist</p>
|
<p className="text-sm font-medium">Artist</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-500 shrink-0"/>)}
|
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
|
||||||
</div>
|
</div>
|
||||||
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
{artistInfo.rank && (<>
|
||||||
|
<span>#{artistInfo.rank} rank</span>
|
||||||
|
<span>•</span>
|
||||||
|
</>)}
|
||||||
|
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
|
||||||
{artistInfo.listeners && (<>
|
{artistInfo.listeners && (<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
|
||||||
</>)}
|
|
||||||
{artistInfo.rank && (<>
|
|
||||||
<span>•</span>
|
|
||||||
<span>#{artistInfo.rank} rank</span>
|
|
||||||
</>)}
|
</>)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
|
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{albumList.length} albums</span>
|
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||||
<span>•</span>
|
|
||||||
<span>{trackList.length} tracks</span>
|
|
||||||
{artistInfo.genres.length > 0 && (<>
|
{artistInfo.genres.length > 0 && (<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{artistInfo.genres.join(", ")}</span>
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
@@ -351,9 +396,23 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4">
|
<div className="border-b">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<button onClick={() => setActiveTab("albums")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "albums" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||||
|
Albums
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab("tracks")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "tracks" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||||
|
All Tracks
|
||||||
|
</button>
|
||||||
|
{hasGallery && (<button onClick={() => setActiveTab("gallery")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "gallery" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||||
|
Gallery
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3>
|
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length})</h3>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||||
@@ -366,7 +425,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{artistInfo.gallery.map((imageUrl, index) => (<div key={index} className="relative group">
|
{artistInfo.gallery!.map((imageUrl, index) => (<div key={index} className="relative group">
|
||||||
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
|
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
|
||||||
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
|
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
||||||
@@ -386,29 +445,76 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{albumList.length > 0 && (<div className="space-y-4">
|
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
|
||||||
<h3 className="text-2xl font-bold">Discography</h3>
|
<h3 className="text-2xl font-bold">Discography</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
|
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
|
||||||
id: album.id,
|
id: album.id,
|
||||||
name: album.name,
|
name: album.name,
|
||||||
external_urls: album.external_urls,
|
external_urls: album.external_urls,
|
||||||
})}>
|
})}>
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-2">
|
||||||
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
||||||
|
<div className="absolute bottom-2 right-2">
|
||||||
|
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
|
||||||
|
{album.album_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||||
|
<span>{album.release_date?.split("-")[0]}</span>
|
||||||
|
{album.total_tracks && (<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold truncate">{album.name}</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{album.release_date?.split("-")[0]}
|
|
||||||
</p>
|
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{trackList.length > 0 && (<div className="space-y-4">
|
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h3 className="text-2xl font-bold">All Tracks</h3>
|
<h3 className="text-2xl font-bold">All Tracks</h3>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Filter className="h-4 w-4"/>
|
||||||
|
Filter Albums
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Select Albums</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="flex-1 pr-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredAlbumGroups.map(([albumName, data]) => {
|
||||||
|
const tracksWithIsrc = data.tracks.filter(t => t.isrc);
|
||||||
|
const isSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(t => selectedTracks.includes(t.isrc!));
|
||||||
|
return (<div key={albumName} className="flex items-start space-x-3 p-2 hover:bg-muted/50 rounded-md transition-colors">
|
||||||
|
<Checkbox id={`album-select-${albumName}`} checked={isSelected} onCheckedChange={() => onToggleSelectAll(data.tracks)} className="mt-1"/>
|
||||||
|
<div className="grid gap-1.5 leading-none flex-1">
|
||||||
|
<label htmlFor={`album-select-${albumName}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer">
|
||||||
|
{albumName}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="capitalize bg-muted px-1.5 py-0.5 rounded text-[10px] font-semibold border">
|
||||||
|
{data.type}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{data.count} tracks</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{data.tracks[0]?.release_date?.split('-')[0] || 'Unknown Year'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
Download All
|
Download All
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { DragEvent } from "react";
|
||||||
|
import { UploadImageBytes, UploadImage, SelectImageVideo } from "../../wailsjs/go/main/App";
|
||||||
|
import { Upload, Loader2, ImagePlus, X, Check, FileVideo, ImageIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
interface UploadedFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
type: 'image' | 'video' | 'unknown';
|
||||||
|
status: 'uploading' | 'done' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
interface DragDropMediaProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
export function DragDropMedia({ value, onChange, className }: DragDropMediaProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [files, setFiles] = useState<UploadedFile[]>(() => {
|
||||||
|
if (!value)
|
||||||
|
return [];
|
||||||
|
return value.split('\n').filter(line => line.trim()).map((line, i) => {
|
||||||
|
const match = line.match(/!\[(.*?)\]\((.*?)\)/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
id: `init-${i}-${Date.now()}`,
|
||||||
|
name: match[1] === 'image' || match[1] === 'video' ? `file-${i}` : match[1],
|
||||||
|
url: match[2] || line,
|
||||||
|
type: (match[2] && match[2].match(/\.(mp4|mkv|webm|mov)$/i)) ? 'video' : 'image',
|
||||||
|
status: 'done'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `init-${i}-${Date.now()}`,
|
||||||
|
name: 'unknown',
|
||||||
|
url: line,
|
||||||
|
type: 'image',
|
||||||
|
status: 'done'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
const newValue = files
|
||||||
|
.filter(f => f.status === 'done' && f.url)
|
||||||
|
.map(f => f.url)
|
||||||
|
.join('\n');
|
||||||
|
if (newValue !== value) {
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
}, [files]);
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
await handleFiles(Array.from(e.dataTransfer.files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleFiles = async (fileList: File[]) => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const newFiles: UploadedFile[] = fileList.map((f, i) => ({
|
||||||
|
id: `drop-${timestamp}-${i}`,
|
||||||
|
name: f.name,
|
||||||
|
url: '',
|
||||||
|
type: f.type.startsWith('video') ? 'video' : 'image',
|
||||||
|
status: 'uploading'
|
||||||
|
}));
|
||||||
|
setFiles(prev => [...prev, ...newFiles]);
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const file = fileList[i];
|
||||||
|
const fileId = newFiles[i].id;
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
const result = await UploadImageBytes(file.name, base64);
|
||||||
|
setFiles(prev => prev.map(f => f.id === fileId
|
||||||
|
? { ...f, status: 'done', url: result }
|
||||||
|
: f));
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error("Upload failed", err);
|
||||||
|
setFiles(prev => prev.map(f => f.id === fileId
|
||||||
|
? { ...f, status: 'error', error: err.message || "Upload failed" }
|
||||||
|
: f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleSelectFile = async () => {
|
||||||
|
try {
|
||||||
|
const paths = await SelectImageVideo();
|
||||||
|
if (paths && paths.length > 0) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const newFiles: UploadedFile[] = paths.map((p, i) => ({
|
||||||
|
id: `select-${timestamp}-${i}`,
|
||||||
|
name: p.split(/[\\/]/).pop() || 'unknown',
|
||||||
|
url: '',
|
||||||
|
type: p.match(/\.(mp4|mkv|webm|mov)$/i) ? 'video' : 'image',
|
||||||
|
status: 'uploading'
|
||||||
|
}));
|
||||||
|
setFiles(prev => [...prev, ...newFiles]);
|
||||||
|
for (let i = 0; i < paths.length; i++) {
|
||||||
|
const path = paths[i];
|
||||||
|
const fileId = newFiles[i].id;
|
||||||
|
try {
|
||||||
|
const result = await UploadImage(path);
|
||||||
|
setFiles(prev => prev.map(f => f.id === fileId
|
||||||
|
? { ...f, status: 'done', url: result }
|
||||||
|
: f));
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
setFiles(prev => prev.map(f => f.id === fileId
|
||||||
|
? { ...f, status: 'error', error: err.message }
|
||||||
|
: f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error("Select file failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
return (<div className={cn("relative group flex flex-col gap-2 p-4 border-2 border-dashed rounded-lg transition-colors border-muted-foreground/25 hover:border-primary/50 min-h-[14rem]", isDragging ? "border-primary bg-primary/10" : "bg-muted/5", className)} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget)
|
||||||
|
handleSelectFile();
|
||||||
|
}}>
|
||||||
|
{files.length === 0 && (<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none opacity-50">
|
||||||
|
<ImagePlus className="h-10 w-10 mb-2"/>
|
||||||
|
<span className="text-sm font-medium">Drop media here or click to browse</span>
|
||||||
|
<span className="text-xs text-muted-foreground mt-1">Supports PNG, JPG, MP4, MOV</span>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 z-10 w-full">
|
||||||
|
{files.map((file, i) => (<div key={i} className="flex items-center gap-3 p-2 rounded-md bg-background/80 backdrop-blur-sm border shadow-sm animate-in fade-in slide-in-from-bottom-2">
|
||||||
|
{file.type === 'video' ? <FileVideo className="h-8 w-8 text-primary"/> : <ImageIcon className="h-8 w-8 text-primary"/>}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{file.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
{file.status === 'uploading' && <span className="text-yellow-500 flex items-center"><Loader2 className="h-3 w-3 animate-spin mr-1"/> Uploading...</span>}
|
||||||
|
{file.status === 'done' && <span className="text-green-500 flex items-center"><Check className="h-3 w-3 mr-1"/> Ready</span>}
|
||||||
|
{file.status === 'error' && <span className="text-red-500">{file.error || 'Failed'}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{isDragging && (<div className="absolute inset-0 flex items-center justify-center bg-background/50 rounded-lg pointer-events-none z-20">
|
||||||
|
<div className="flex flex-col items-center text-primary font-medium">
|
||||||
|
<Upload className="h-10 w-10 mb-2 animate-bounce"/>
|
||||||
|
<span>Drop files to add</span>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
const fileToBase64 = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = (error) => reject(error);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause } from "lucide-react";
|
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL } from "../../wailsjs/go/main/App";
|
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
@@ -19,7 +19,7 @@ const formatDate = (timestamp: number) => {
|
|||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
};
|
};
|
||||||
interface HistoryItem {
|
interface DownloadHistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -32,65 +32,77 @@ interface HistoryItem {
|
|||||||
path: string;
|
path: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
export function HistoryPage() {
|
interface FetchHistoryItem {
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
id: string;
|
||||||
const [filteredHistory, setFilteredHistory] = useState<HistoryItem[]>([]);
|
url: string;
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
type: string;
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
name: string;
|
||||||
const [sortBy, setSortBy] = useState("default");
|
info: string;
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
image: string;
|
||||||
|
data: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
interface HistoryPageProps {
|
||||||
|
onHistorySelect?: (cachedData: string) => void;
|
||||||
|
}
|
||||||
|
export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState("downloads");
|
||||||
|
const [downloadHistory, setDownloadHistory] = useState<DownloadHistoryItem[]>([]);
|
||||||
|
const [filteredDownloadHistory, setFilteredDownloadHistory] = useState<DownloadHistoryItem[]>([]);
|
||||||
|
const [showClearDownloadConfirm, setShowClearDownloadConfirm] = useState(false);
|
||||||
|
const [downloadSearchQuery, setDownloadSearchQuery] = useState("");
|
||||||
|
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
||||||
|
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
||||||
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||||
|
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||||
|
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
||||||
|
const [showClearFetchConfirm, setShowClearFetchConfirm] = useState(false);
|
||||||
|
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
|
||||||
|
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const fetchHistory = async () => {
|
const fetchDownloadHistory = async () => {
|
||||||
try {
|
try {
|
||||||
const items = await GetDownloadHistory();
|
const items = await GetDownloadHistory();
|
||||||
setHistory(items || []);
|
setDownloadHistory(items || []);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Failed to fetch history:", err);
|
console.error("Failed to fetch download history:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchFetchHistory = async () => {
|
||||||
|
try {
|
||||||
|
const items = await GetFetchHistory();
|
||||||
|
setFetchHistory(items || []);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to fetch fetch history:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHistory();
|
if (activeTab === "downloads") {
|
||||||
const interval = setInterval(fetchHistory, 5000);
|
fetchDownloadHistory();
|
||||||
|
const interval = setInterval(fetchDownloadHistory, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fetchFetchHistory();
|
||||||
|
const interval = setInterval(fetchFetchHistory, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePreview = async (id: string, spotifyId: string) => {
|
|
||||||
if (playingPreviewId === id) {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
setPlayingPreviewId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = await GetPreviewURL(spotifyId);
|
|
||||||
if (url) {
|
|
||||||
const audio = new Audio(url);
|
|
||||||
audioRef.current = audio;
|
|
||||||
audio.volume = 0.5;
|
|
||||||
audio.onended = () => setPlayingPreviewId(null);
|
|
||||||
audio.play();
|
|
||||||
setPlayingPreviewId(id);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to play preview:", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let result = [...history];
|
let result = [...downloadHistory];
|
||||||
if (searchQuery) {
|
if (downloadSearchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = downloadSearchQuery.toLowerCase();
|
||||||
result = result.filter(item => item.title.toLowerCase().includes(query) ||
|
result = result.filter(item => item.title.toLowerCase().includes(query) ||
|
||||||
item.artists.toLowerCase().includes(query) ||
|
item.artists.toLowerCase().includes(query) ||
|
||||||
item.album.toLowerCase().includes(query));
|
item.album.toLowerCase().includes(query));
|
||||||
@@ -104,38 +116,82 @@ export function HistoryPage() {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (downloadSortBy) {
|
||||||
case "default":
|
case "default":
|
||||||
case "date_desc":
|
case "date_desc": return b.timestamp - a.timestamp;
|
||||||
return b.timestamp - a.timestamp;
|
case "date_asc": return a.timestamp - b.timestamp;
|
||||||
case "date_asc":
|
case "title_asc": return a.title.localeCompare(b.title);
|
||||||
return a.timestamp - b.timestamp;
|
case "title_desc": return b.title.localeCompare(a.title);
|
||||||
case "title_asc":
|
case "artist_asc": return a.artists.localeCompare(b.artists);
|
||||||
return a.title.localeCompare(b.title);
|
case "artist_desc": return b.artists.localeCompare(a.artists);
|
||||||
case "title_desc":
|
case "duration_asc": return parseDuration(a.duration_str) - parseDuration(b.duration_str);
|
||||||
return b.title.localeCompare(a.title);
|
case "duration_desc": return parseDuration(b.duration_str) - parseDuration(a.duration_str);
|
||||||
case "artist_asc":
|
default: return 0;
|
||||||
return a.artists.localeCompare(b.artists);
|
|
||||||
case "artist_desc":
|
|
||||||
return b.artists.localeCompare(a.artists);
|
|
||||||
case "duration_asc":
|
|
||||||
return parseDuration(a.duration_str) - parseDuration(b.duration_str);
|
|
||||||
case "duration_desc":
|
|
||||||
return parseDuration(b.duration_str) - parseDuration(a.duration_str);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setFilteredHistory(result);
|
setFilteredDownloadHistory(result);
|
||||||
setCurrentPage(1);
|
setDownloadCurrentPage(1);
|
||||||
}, [history, searchQuery, sortBy]);
|
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
|
||||||
const totalPages = Math.ceil(filteredHistory.length / ITEMS_PER_PAGE);
|
useEffect(() => {
|
||||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
let result = [...fetchHistory];
|
||||||
const paginatedHistory = filteredHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
if (activeFetchTab !== "all") {
|
||||||
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
|
result = result.filter(item => item.type.toLowerCase() === activeFetchTab.toLowerCase());
|
||||||
if (total <= 10) {
|
|
||||||
return Array.from({ length: total }, (_, i) => i + 1);
|
|
||||||
}
|
}
|
||||||
|
if (fetchSearchQuery) {
|
||||||
|
const query = fetchSearchQuery.toLowerCase();
|
||||||
|
result = result.filter(item => item.name.toLowerCase().includes(query) ||
|
||||||
|
item.info.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
result.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
setFilteredFetchHistory(result);
|
||||||
|
setFetchCurrentPage(1);
|
||||||
|
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
|
||||||
|
const handlePreview = async (id: string, spotifyId: string) => {
|
||||||
|
if (playingPreviewId === id) {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
setPlayingPreviewId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await GetPreviewURL(spotifyId);
|
||||||
|
if (url) {
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audioRef.current = audio;
|
||||||
|
audio.volume = 0.5;
|
||||||
|
audio.onended = () => setPlayingPreviewId(null);
|
||||||
|
audio.play();
|
||||||
|
setPlayingPreviewId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error("Failed to play preview:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleClearDownloadHistory = async () => {
|
||||||
|
await ClearDownloadHistory();
|
||||||
|
fetchDownloadHistory();
|
||||||
|
setShowClearDownloadConfirm(false);
|
||||||
|
};
|
||||||
|
const handleDeleteDownloadItem = async (id: string) => {
|
||||||
|
await DeleteDownloadHistoryItem(id);
|
||||||
|
setDownloadHistory(prev => prev.filter(item => item.id !== id));
|
||||||
|
};
|
||||||
|
const handleClearFetchHistory = async () => {
|
||||||
|
await ClearFetchHistoryByType(activeFetchTab);
|
||||||
|
fetchFetchHistory();
|
||||||
|
setShowClearFetchConfirm(false);
|
||||||
|
};
|
||||||
|
const handleDeleteFetchItem = async (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await DeleteFetchHistoryItem(id);
|
||||||
|
setFetchHistory(prev => prev.filter(item => item.id !== id));
|
||||||
|
};
|
||||||
|
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
|
||||||
|
if (total <= 10)
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
const pages: (number | 'ellipsis')[] = [];
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
pages.push(1);
|
pages.push(1);
|
||||||
if (current <= 7) {
|
if (current <= 7) {
|
||||||
@@ -159,188 +215,397 @@ export function HistoryPage() {
|
|||||||
}
|
}
|
||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
const handleClearHistory = async () => {
|
const renderDownloadHistory = () => {
|
||||||
await ClearDownloadHistory();
|
const totalPages = Math.ceil(filteredDownloadHistory.length / ITEMS_PER_PAGE);
|
||||||
fetchHistory();
|
const startIndex = (downloadCurrentPage - 1) * ITEMS_PER_PAGE;
|
||||||
setShowClearConfirm(false);
|
const paginated = filteredDownloadHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||||
|
return (<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
|
||||||
|
{downloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||||
|
{downloadHistory.length.toLocaleString('en-US')}
|
||||||
|
</Badge>)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
|
||||||
|
<Trash2 className="h-4 w-4"/> Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
|
||||||
|
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||||
|
</div>
|
||||||
|
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
||||||
|
<SelectTrigger className="w-[180px] h-9">
|
||||||
|
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
||||||
|
<SelectValue placeholder="Sort by"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="date_desc">Date (Newest)</SelectItem>
|
||||||
|
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
|
||||||
|
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
|
||||||
|
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
|
||||||
|
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
|
||||||
|
<History className="h-10 w-10 opacity-40"/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-foreground/80">No download history</p>
|
||||||
|
<p className="text-sm">Your downloaded tracks will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</div>) : (<table className="w-full table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
|
||||||
|
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
|
||||||
|
{startIndex + index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle min-w-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-sm truncate">{item.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
|
<div className="truncate">{item.album}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<span className="text-xs font-bold text-foreground">
|
||||||
|
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
|
||||||
|
</span>
|
||||||
|
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
||||||
|
{item.duration_str}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
|
||||||
|
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
|
||||||
|
<ExternalLink className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Open in Spotify</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={() => handleDeleteDownloadItem(item.id)}>
|
||||||
|
<Trash2 className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Delete</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</table>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious href="#" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (downloadCurrentPage > 1)
|
||||||
|
setDownloadCurrentPage(downloadCurrentPage - 1);
|
||||||
|
}} className={downloadCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPaginationPages(downloadCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>) : (<PaginationItem key={page}>
|
||||||
|
<PaginationLink href="#" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDownloadCurrentPage(page as number);
|
||||||
|
}} isActive={downloadCurrentPage === page} className="cursor-pointer">
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>)))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext href="#" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (downloadCurrentPage < totalPages)
|
||||||
|
setDownloadCurrentPage(downloadCurrentPage + 1);
|
||||||
|
}} className={downloadCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>)}
|
||||||
|
</div>);
|
||||||
|
};
|
||||||
|
const renderFetchHistory = () => {
|
||||||
|
const totalPages = Math.ceil(filteredFetchHistory.length / ITEMS_PER_PAGE);
|
||||||
|
const startIndex = (fetchCurrentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const paginated = filteredFetchHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||||
|
return (<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">Fetches</h2>
|
||||||
|
{fetchHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||||
|
{fetchHistory.length.toLocaleString('en-US')}
|
||||||
|
</Badge>)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowClearFetchConfirm(true)} disabled={fetchHistory.length === 0} className="cursor-pointer gap-2">
|
||||||
|
<Trash2 className="h-4 w-4"/> Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
|
<Button variant={activeFetchTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("track")} className="rounded-b-none">
|
||||||
|
<Music2 className="h-4 w-4"/>
|
||||||
|
Tracks
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeFetchTab === "album" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("album")} className="rounded-b-none">
|
||||||
|
<Disc3 className="h-4 w-4"/>
|
||||||
|
Albums
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeFetchTab === "playlist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("playlist")} className="rounded-b-none">
|
||||||
|
<ListMusic className="h-4 w-4"/>
|
||||||
|
Playlists
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeFetchTab === "artist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("artist")} className="rounded-b-none">
|
||||||
|
<UserRound className="h-4 w-4"/>
|
||||||
|
Artists
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
|
||||||
|
<Input placeholder="Search fetch history..." value={fetchSearchQuery} onChange={(e) => setFetchSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground gap-3">
|
||||||
|
<Database className="h-10 w-10 opacity-40"/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-foreground/80">No fetch history</p>
|
||||||
|
<p className="text-sm">Fetched metadata will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</div>) : (<table className="w-full table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase w-1/3">
|
||||||
|
{activeFetchTab === 'artist' ? 'Name' : 'Title'}
|
||||||
|
</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase">Details</th>
|
||||||
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-40 text-xs uppercase text-nowrap">Fetched At</th>
|
||||||
|
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
|
||||||
|
{startIndex + index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle min-w-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="h-10 w-10 rounded shrink-0 bg-secondary overflow-hidden">
|
||||||
|
{item.image ? (<img src={item.image} alt={item.name} className="h-full w-full object-cover"/>) : (<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground font-medium bg-muted">
|
||||||
|
{item.type.slice(0, 2).toUpperCase()}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-sm truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
|
<div className="truncate">{item.info}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-xs text-muted-foreground hidden lg:table-cell whitespace-nowrap">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => onHistorySelect?.(item.data)}>
|
||||||
|
<CloudUpload className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Load</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={(e) => handleDeleteFetchItem(item.id, e)}>
|
||||||
|
<Trash2 className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Delete</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</table>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious href="#" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (fetchCurrentPage > 1)
|
||||||
|
setFetchCurrentPage(fetchCurrentPage - 1);
|
||||||
|
}} className={fetchCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPaginationPages(fetchCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>) : (<PaginationItem key={page}>
|
||||||
|
<PaginationLink href="#" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFetchCurrentPage(page as number);
|
||||||
|
}} isActive={fetchCurrentPage === page} className="cursor-pointer">
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>)))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext href="#" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (fetchCurrentPage < totalPages)
|
||||||
|
setFetchCurrentPage(fetchCurrentPage + 1);
|
||||||
|
}} className={fetchCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>)}
|
||||||
|
</div>);
|
||||||
};
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<h1 className="text-2xl font-bold">History</h1>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Download History</h2>
|
|
||||||
{history.length > 0 && (<Badge variant="secondary" className="font-mono">
|
|
||||||
{history.length.toLocaleString('en-US')}
|
|
||||||
</Badge>)}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowClearConfirm(true)} disabled={history.length === 0} className="cursor-pointer gap-2">
|
|
||||||
<Trash2 className="h-4 w-4" /> Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="border-b">
|
||||||
<div className="relative flex-1">
|
<div className="flex gap-6">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<button onClick={() => setActiveTab("downloads")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "downloads" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||||
<Input placeholder="Search history..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-8 h-9" />
|
Downloads
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab("fetches")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "fetches" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||||
|
Fetches
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
|
||||||
<SelectTrigger className="w-[180px] h-9">
|
|
||||||
<ArrowUpDown className="mr-2 h-4 w-4" />
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="default">Default</SelectItem>
|
|
||||||
<SelectItem value="date_desc">Date (Newest)</SelectItem>
|
|
||||||
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
|
|
||||||
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
|
|
||||||
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
|
|
||||||
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
|
|
||||||
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
|
|
||||||
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
|
|
||||||
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border overflow-hidden">
|
{activeTab === "downloads" && (<div className="mt-6">
|
||||||
{paginatedHistory.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
|
{renderDownloadHistory()}
|
||||||
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
|
</div>)}
|
||||||
<History className="h-10 w-10 opacity-40" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-medium text-foreground/80">No download history</p>
|
|
||||||
<p className="text-sm">Your downloaded tracks will appear here.</p>
|
|
||||||
</div>
|
|
||||||
</div>) : (<table className="w-full table-fixed">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/50">
|
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
|
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
|
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-40 text-xs uppercase text-nowrap">Downloaded At</th>
|
|
||||||
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-20 text-xs uppercase text-nowrap">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{paginatedHistory.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
|
|
||||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
|
|
||||||
{startIndex + index + 1}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle min-w-0">
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }} />
|
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
|
||||||
<span className="font-medium text-sm truncate">{item.title}</span>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
|
||||||
<div className="truncate">{item.album}</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
|
||||||
<div className="flex flex-col items-start gap-1">
|
|
||||||
<span className="text-xs font-bold text-foreground">
|
|
||||||
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
|
|
||||||
</span>
|
|
||||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
|
||||||
{item.duration_str}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 align-middle text-center">
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
|
|
||||||
{playingPreviewId === item.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
{activeTab === "fetches" && (<div className="mt-6">
|
||||||
<Tooltip delayDuration={0}>
|
{renderFetchHistory()}
|
||||||
<TooltipTrigger asChild>
|
</div>)}
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Open in Spotify</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>))}
|
|
||||||
</tbody>
|
|
||||||
</table>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
<Dialog open={showClearDownloadConfirm} onOpenChange={setShowClearDownloadConfirm}>
|
||||||
totalPages > 1 && (<Pagination>
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
<PaginationContent>
|
<DialogHeader>
|
||||||
<PaginationItem>
|
<DialogTitle>Clear Download History?</DialogTitle>
|
||||||
<PaginationPrevious href="#" onClick={(e) => {
|
<DialogDescription>
|
||||||
e.preventDefault();
|
This will remove all entries from your download history. This action cannot be undone.
|
||||||
if (currentPage > 1)
|
Note: The actual downloaded files will NOT be deleted.
|
||||||
setCurrentPage(currentPage - 1);
|
</DialogDescription>
|
||||||
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} />
|
</DialogHeader>
|
||||||
</PaginationItem>
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowClearDownloadConfirm(false)} className="cursor-pointer">Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleClearDownloadHistory} className="cursor-pointer">
|
||||||
|
Clear History
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
<Dialog open={showClearFetchConfirm} onOpenChange={setShowClearFetchConfirm}>
|
||||||
<PaginationEllipsis />
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
</PaginationItem>) : (<PaginationItem key={page}>
|
<DialogHeader>
|
||||||
<PaginationLink href="#" onClick={(e) => {
|
<DialogTitle>Clear {activeFetchTab.charAt(0).toUpperCase() + activeFetchTab.slice(1)} History?</DialogTitle>
|
||||||
e.preventDefault();
|
<DialogDescription>
|
||||||
setCurrentPage(page);
|
This will remove all {activeFetchTab} entries from your fetch history cache.
|
||||||
}} isActive={currentPage === page} className="cursor-pointer">
|
</DialogDescription>
|
||||||
{page}
|
</DialogHeader>
|
||||||
</PaginationLink>
|
<DialogFooter>
|
||||||
</PaginationItem>)))}
|
<Button variant="outline" onClick={() => setShowClearFetchConfirm(false)} className="cursor-pointer">Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleClearFetchHistory} className="cursor-pointer">
|
||||||
<PaginationItem>
|
Clear History
|
||||||
<PaginationNext href="#" onClick={(e) => {
|
</Button>
|
||||||
e.preventDefault();
|
</DialogFooter>
|
||||||
if (currentPage < totalPages)
|
</DialogContent>
|
||||||
setCurrentPage(currentPage + 1);
|
</Dialog>
|
||||||
}} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} />
|
</div>);
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>)
|
|
||||||
}
|
|
||||||
|
|
||||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Clear Download History?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This will remove all entries from your download history. This action cannot be undone.
|
|
||||||
Note: The actual downloaded files will NOT be deleted.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowClearConfirm(false)} className="cursor-pointer">Cancel</Button>
|
|
||||||
<Button variant="destructive" onClick={handleClearHistory} className="cursor-pointer">
|
|
||||||
Clear History
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div >);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, ImageDown, FileText } from "lucide-react";
|
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
@@ -78,10 +78,16 @@ interface PlaylistInfoProps {
|
|||||||
external_urls: string;
|
external_urls: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onTrackClick: (track: TrackMetadata) => void;
|
onTrackClick: (track: TrackMetadata) => void;
|
||||||
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: PlaylistInfoProps) {
|
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<Card>
|
<Card className="relative">
|
||||||
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<XCircle className="h-5 w-5"/>
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||||
@@ -100,7 +106,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
|
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
<span>{playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
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 { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
|
import { CloudDownload, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FetchHistory } from "@/components/FetchHistory";
|
import { FetchHistory } from "@/components/FetchHistory";
|
||||||
@@ -9,6 +9,34 @@ import type { HistoryItem } from "@/components/FetchHistory";
|
|||||||
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
||||||
import { backend } from "../../wailsjs/go/models";
|
import { backend } from "../../wailsjs/go/models";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTypingEffect } from "@/hooks/useTypingEffect";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
const FETCH_PLACEHOLDERS = [
|
||||||
|
"https://open.spotify.com/track/...",
|
||||||
|
"https://open.spotify.com/album/...",
|
||||||
|
"https://open.spotify.com/playlist/...",
|
||||||
|
"https://open.spotify.com/artist/..."
|
||||||
|
];
|
||||||
|
const SEARCH_PLACEHOLDERS = [
|
||||||
|
"Golden",
|
||||||
|
"Taylor Swift",
|
||||||
|
"The Weeknd",
|
||||||
|
"Starboy",
|
||||||
|
"Joji",
|
||||||
|
"Die For You"
|
||||||
|
];
|
||||||
|
const REGIONS = ["AD", "AE", "AG", "AL", "AM", "AO", "AR", "AT", "AU", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BZ", "CA", "CD", "CG", "CH", "CI", "CL", "CM", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ES", "ET", "FI", "FJ", "FM", "FR", "GA", "GB", "GD", "GE", "GH", "GM", "GN", "GQ", "GR", "GT", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MN", "MO", "MR", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NE", "NG", "NI", "NL", "NO", "NP", "NR", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PS", "PT", "PW", "PY", "QA", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SZ", "TD", "TG", "TH", "TJ", "TL", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VN", "VU", "WS", "XK", "ZA", "ZM", "ZW"];
|
||||||
|
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||||
|
const getRegionName = (code: string) => {
|
||||||
|
try {
|
||||||
|
if (code === "XK")
|
||||||
|
return "Kosovo";
|
||||||
|
return regionNames.of(code) || code;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
};
|
||||||
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
|
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
|
||||||
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
|
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
|
||||||
const MAX_RECENT_SEARCHES = 8;
|
const MAX_RECENT_SEARCHES = 8;
|
||||||
@@ -25,8 +53,10 @@ interface SearchBarProps {
|
|||||||
hasResult: boolean;
|
hasResult: boolean;
|
||||||
searchMode: boolean;
|
searchMode: boolean;
|
||||||
onSearchModeChange: (isSearch: boolean) => void;
|
onSearchModeChange: (isSearch: boolean) => void;
|
||||||
|
region: string;
|
||||||
|
onRegionChange: (region: string) => void;
|
||||||
}
|
}
|
||||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) {
|
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange }: SearchBarProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -41,6 +71,8 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
playlists: false,
|
playlists: false,
|
||||||
});
|
});
|
||||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
|
||||||
|
const placeholderText = useTypingEffect(placeholders);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
|
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||||
@@ -202,172 +234,167 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
{ key: "playlists", label: "Playlists" },
|
{ key: "playlists", label: "Playlists" },
|
||||||
];
|
];
|
||||||
return (<div className="space-y-4">
|
return (<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
|
||||||
|
{searchMode ? <Link className="h-4 w-4"/> : <Search className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<div className="flex items-center bg-muted rounded-md p-1">
|
<div className="relative flex-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
|
{!searchMode ? (<>
|
||||||
? "bg-background text-foreground shadow-sm"
|
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
|
||||||
: "text-muted-foreground hover:text-foreground")}>
|
{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("")}>
|
||||||
<Link className="h-3.5 w-3.5"/>
|
<XCircle className="h-4 w-4"/>
|
||||||
URL
|
</button>)}
|
||||||
</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
|
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
||||||
? "bg-background text-foreground shadow-sm"
|
{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={() => {
|
||||||
: "text-muted-foreground hover:text-foreground")}>
|
|
||||||
<Search className="h-3.5 w-3.5"/>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="h-4 w-4 text-muted-foreground cursor-help"/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
{!searchMode ? (<>
|
|
||||||
<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>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
{!searchMode ? (<>
|
|
||||||
<InputWithContext 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>)}
|
|
||||||
</>) : (<>
|
|
||||||
<InputWithContext 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("");
|
setSearchQuery("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setLastSearchedQuery("");
|
setLastSearchedQuery("");
|
||||||
}}>
|
}}>
|
||||||
<XCircle className="h-4 w-4"/>
|
<XCircle className="h-4 w-4"/>
|
||||||
</button>)}
|
</button>)}
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!searchMode && (<Button onClick={onFetch} disabled={loading}>
|
{!searchMode && (<>
|
||||||
{loading ? (<>
|
<Select value={region} onValueChange={onRegionChange}>
|
||||||
<Spinner />
|
<SelectTrigger className="w-[70px] shrink-0">
|
||||||
Fetching...
|
<SelectValue placeholder="Region"/>
|
||||||
</>) : (<>
|
</SelectTrigger>
|
||||||
<CloudDownload className="h-4 w-4"/>
|
<SelectContent className="max-h-[300px]">
|
||||||
Fetch
|
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||||
</>)}
|
{r} <span className="text-muted-foreground">({getRegionName(r)})</span>
|
||||||
</Button>)}
|
</SelectItem>))}
|
||||||
</div>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
<Button onClick={onFetch} disabled={loading}>
|
||||||
|
{loading ? (<>
|
||||||
|
<Spinner />
|
||||||
|
Fetching...
|
||||||
|
</>) : (<>
|
||||||
|
<CloudDownload className="h-4 w-4"/>
|
||||||
|
Fetch
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
||||||
|
|
||||||
|
{searchMode && (<div className="space-y-4">
|
||||||
{searchMode && (<div className="space-y-4">
|
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
||||||
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
{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)}>
|
||||||
<div className="flex flex-wrap gap-2">
|
<span>{query}</span>
|
||||||
{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)}>
|
<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) => {
|
||||||
<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();
|
e.stopPropagation();
|
||||||
removeRecentSearch(query);
|
removeRecentSearch(query);
|
||||||
}}>
|
}}>
|
||||||
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||||
</button>
|
</button>
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{isSearching && (<div className="flex items-center justify-center py-8">
|
{isSearching && (<div className="flex items-center justify-center py-8">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span className="ml-2 text-muted-foreground">Searching...</span>
|
<span className="ml-2 text-muted-foreground">Searching...</span>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
||||||
No results found for "{searchQuery}"
|
No results found for "{searchQuery}"
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{!isSearching && hasAnyResults && (<>
|
{!isSearching && hasAnyResults && (<>
|
||||||
|
<div className="flex gap-1 border-b">
|
||||||
<div className="flex gap-1 border-b">
|
{tabs.map((tab) => {
|
||||||
{tabs.map((tab) => {
|
|
||||||
const count = getTabCount(tab.key);
|
const count = getTabCount(tab.key);
|
||||||
if (count === 0)
|
if (count === 0)
|
||||||
return null;
|
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
|
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-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
||||||
{tab.label} ({count})
|
{tab.label} ({count})
|
||||||
</button>);
|
</button>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{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">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<p className="font-medium truncate">{track.name}</p>
|
||||||
|
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
|
||||||
|
E
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<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>))}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
{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.release_date || ""}
|
||||||
|
</span>
|
||||||
|
</button>))}
|
||||||
|
|
||||||
{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)}>
|
{activeTab === "artists" &&
|
||||||
{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"/>)}
|
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)}>
|
||||||
<div className="flex-1 min-w-0">
|
{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"/>)}
|
||||||
<p className="font-medium truncate">{track.name}</p>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
|
<p className="font-medium truncate">{artist.name}</p>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">Artist</p>
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
</div>
|
||||||
{formatDuration(track.duration_ms || 0)}
|
</button>))}
|
||||||
</span>
|
|
||||||
</button>))}
|
|
||||||
|
|
||||||
|
{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 || ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{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)}>
|
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||||
{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"/>)}
|
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||||
<div className="flex-1 min-w-0">
|
{isLoadingMore ? (<>
|
||||||
<p className="font-medium truncate">{album.name}</p>
|
<Spinner />
|
||||||
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
|
Loading...
|
||||||
</div>
|
</>) : (<>
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
<ChevronDown className="h-4 w-4"/>
|
||||||
{album.release_date || ""}
|
Load More
|
||||||
</span>
|
</>)}
|
||||||
</button>))}
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
</>)}
|
||||||
{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>))}
|
|
||||||
|
|
||||||
|
|
||||||
{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 || ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{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>)}
|
|
||||||
</div>);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const [showHiResWarning, setShowHiResWarning] = useState(false);
|
|
||||||
const [pendingQuality, setPendingQuality] = useState<{
|
|
||||||
type: 'tidal' | 'qobuz' | 'auto';
|
|
||||||
value: string;
|
|
||||||
} | null>(null);
|
|
||||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||||
const resetToSaved = useCallback(() => {
|
const resetToSaved = useCallback(() => {
|
||||||
const freshSavedSettings = getSettings();
|
const freshSavedSettings = getSettings();
|
||||||
@@ -121,53 +116,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||||
if (value === "HI_RES_LOSSLESS") {
|
|
||||||
setPendingQuality({ type: 'tidal', value });
|
|
||||||
setShowHiResWarning(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||||
};
|
};
|
||||||
const handleQobuzQualityChange = (value: "6" | "7") => {
|
const handleQobuzQualityChange = (value: "6" | "7") => {
|
||||||
if (value === "7") {
|
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||||
setPendingQuality({ type: 'qobuz', value });
|
|
||||||
setShowHiResWarning(true);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||||
if (value === "24") {
|
|
||||||
setPendingQuality({ type: 'auto', value });
|
|
||||||
setShowHiResWarning(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||||
};
|
};
|
||||||
const handleConfirmHiRes = () => {
|
return (<div className="space-y-4">
|
||||||
if (pendingQuality) {
|
|
||||||
if (pendingQuality.type === 'tidal') {
|
|
||||||
setTempSettings((prev) => ({ ...prev, tidalQuality: pendingQuality.value as "LOSSLESS" | "HI_RES_LOSSLESS" }));
|
|
||||||
}
|
|
||||||
else if (pendingQuality.type === 'qobuz') {
|
|
||||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: pendingQuality.value as "6" | "7" }));
|
|
||||||
}
|
|
||||||
else if (pendingQuality.type === 'auto') {
|
|
||||||
setTempSettings((prev) => ({ ...prev, autoQuality: pendingQuality.value as "16" | "24" }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setShowHiResWarning(false);
|
|
||||||
setPendingQuality(null);
|
|
||||||
};
|
|
||||||
return (<div className="space-y-6">
|
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="download-path">Download Path</Label>
|
<Label htmlFor="download-path">Download Path</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
|
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
|
||||||
@@ -179,7 +143,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="theme-mode">Mode</Label>
|
<Label htmlFor="theme-mode">Mode</Label>
|
||||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||||
<SelectTrigger id="theme-mode">
|
<SelectTrigger id="theme-mode">
|
||||||
@@ -194,7 +158,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="theme">Accent</Label>
|
<Label htmlFor="theme">Accent</Label>
|
||||||
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
||||||
<SelectTrigger id="theme">
|
<SelectTrigger id="theme">
|
||||||
@@ -214,7 +178,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="font">Font</Label>
|
<Label htmlFor="font">Font</Label>
|
||||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||||
<SelectTrigger id="font">
|
<SelectTrigger id="font">
|
||||||
@@ -236,9 +200,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
||||||
@@ -342,6 +306,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
|
(tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "7") ||
|
||||||
|
(tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pl-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/>
|
||||||
|
<Label htmlFor="allow-fallback" className="text-sm font-normal">Allow Quality Fallback (16-bit)</Label>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
||||||
@@ -356,7 +330,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
<div className="border-t"/>
|
<div className="border-t"/>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-sm">Folder Structure</Label>
|
<Label className="text-sm">Folder Structure</Label>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -394,7 +368,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
<div className="border-t"/>
|
<div className="border-t"/>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-sm">Filename Format</Label>
|
<Label className="text-sm">Filename Format</Label>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -432,7 +406,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-between pt-4 border-t">
|
<div className="flex gap-2 justify-between pt-3 border-t">
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||||
<RotateCcw className="h-4 w-4"/>
|
<RotateCcw className="h-4 w-4"/>
|
||||||
Reset to Default
|
Reset to Default
|
||||||
@@ -459,19 +433,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={showHiResWarning} onOpenChange={setShowHiResWarning}>
|
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>24-bit Quality Warning</DialogTitle>
|
|
||||||
<DialogDescription className="pt-2">
|
|
||||||
If 24-bit is unavailable, downloads will automatically fallback to 16-bit.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowHiResWarning(false)}>Disagree</Button>
|
|
||||||
<Button onClick={handleConfirmHiRes}>Agree</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Download History</p>
|
<p>History</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ interface TrackInfoProps {
|
|||||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => 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;
|
||||||
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
|
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
|
||||||
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||||
const formatDuration = (ms: number) => {
|
const formatDuration = (ms: number) => {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
@@ -45,7 +46,12 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
return plays;
|
return plays;
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
};
|
};
|
||||||
return (<Card>
|
return (<Card className="relative">
|
||||||
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<XCircle className="h-5 w-5"/>
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
@@ -60,6 +66,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||||
|
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
||||||
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
||||||
{track.name}
|
{track.name}
|
||||||
</span>) : (<span className="font-medium">{track.name}</span>)}
|
</span>) : (<span className="font-medium">{track.name}</span>)}
|
||||||
|
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
||||||
|
|
||||||
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
const ScrollArea = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>>(({ className, children, ...props }, ref) => (<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
const ScrollBar = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>(({ className, orientation = "vertical", ...props }, ref) => (<ScrollAreaPrimitive.ScrollAreaScrollbar ref={ref} orientation={orientation} className={cn("flex touch-none select-none transition-colors", orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]", orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]", className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border"/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -19,6 +19,7 @@ interface CheckFileExistenceRequest {
|
|||||||
filename_format?: string;
|
filename_format?: string;
|
||||||
include_track_number?: boolean;
|
include_track_number?: boolean;
|
||||||
audio_format?: string;
|
audio_format?: string;
|
||||||
|
relative_path?: string;
|
||||||
}
|
}
|
||||||
interface FileExistenceResult {
|
interface FileExistenceResult {
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
@@ -29,7 +30,7 @@ interface FileExistenceResult {
|
|||||||
}
|
}
|
||||||
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
|
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
|
||||||
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
|
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
|
||||||
export function useDownload() {
|
export function useDownload(region: string) {
|
||||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||||
@@ -141,7 +142,7 @@ export function useDownload() {
|
|||||||
if (spotifyId) {
|
if (spotifyId) {
|
||||||
try {
|
try {
|
||||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
const urlsJson = await GetStreamingURLs(spotifyId);
|
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||||
streamingURLs = JSON.parse(urlsJson);
|
streamingURLs = JSON.parse(urlsJson);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -372,8 +373,9 @@ export function useDownload() {
|
|||||||
year: yearValue,
|
year: yearValue,
|
||||||
playlist: folderName?.replace(/\//g, placeholder),
|
playlist: folderName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
if (folderName && (!isAlbum || !useAlbumTag)) {
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
|
if (folderName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -391,7 +393,7 @@ export function useDownload() {
|
|||||||
if (spotifyId) {
|
if (spotifyId) {
|
||||||
try {
|
try {
|
||||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
const urlsJson = await GetStreamingURLs(spotifyId);
|
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||||
streamingURLs = JSON.parse(urlsJson);
|
streamingURLs = JSON.parse(urlsJson);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { useState } from "react";
|
|||||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { AddFetchHistory } from "../../wailsjs/go/main/App";
|
||||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
export function useMetadata() {
|
export function useMetadata() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||||
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
|
||||||
const [timeoutValue, setTimeoutValue] = useState(60);
|
|
||||||
const [pendingUrl, setPendingUrl] = useState("");
|
|
||||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +25,57 @@ export function useMetadata() {
|
|||||||
return "artist";
|
return "artist";
|
||||||
return "unknown";
|
return "unknown";
|
||||||
};
|
};
|
||||||
|
const saveToHistory = async (url: string, data: SpotifyMetadataResponse) => {
|
||||||
|
try {
|
||||||
|
let name = "";
|
||||||
|
let info = "";
|
||||||
|
let image = "";
|
||||||
|
let type = "unknown";
|
||||||
|
if ("track" in data) {
|
||||||
|
type = "track";
|
||||||
|
name = data.track.name;
|
||||||
|
info = data.track.artists;
|
||||||
|
image = (data.track.images && data.track.images.length > 0) ? data.track.images : "";
|
||||||
|
}
|
||||||
|
else if ("album_info" in data) {
|
||||||
|
type = "album";
|
||||||
|
name = data.album_info.name;
|
||||||
|
info = `${data.track_list.length} tracks`;
|
||||||
|
image = data.album_info.images;
|
||||||
|
}
|
||||||
|
else if ("playlist_info" in data) {
|
||||||
|
type = "playlist";
|
||||||
|
if (data.playlist_info.name) {
|
||||||
|
name = data.playlist_info.name;
|
||||||
|
}
|
||||||
|
else if (data.playlist_info.owner.name) {
|
||||||
|
name = data.playlist_info.owner.name;
|
||||||
|
}
|
||||||
|
info = `${data.playlist_info.tracks.total} tracks`;
|
||||||
|
image = data.playlist_info.cover || "";
|
||||||
|
}
|
||||||
|
else if ("artist_info" in data) {
|
||||||
|
type = "artist";
|
||||||
|
name = data.artist_info.name;
|
||||||
|
info = `${data.artist_info.total_albums || data.album_list.length} albums`;
|
||||||
|
image = data.artist_info.images;
|
||||||
|
}
|
||||||
|
const jsonStr = JSON.stringify(data);
|
||||||
|
await AddFetchHistory({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
url: url,
|
||||||
|
type: type,
|
||||||
|
name: name,
|
||||||
|
info: info,
|
||||||
|
image: image,
|
||||||
|
data: jsonStr,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to save fetch history:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
const fetchMetadataDirectly = async (url: string) => {
|
const fetchMetadataDirectly = async (url: string) => {
|
||||||
const urlType = getUrlType(url);
|
const urlType = getUrlType(url);
|
||||||
logger.info(`fetching ${urlType} metadata...`);
|
logger.info(`fetching ${urlType} metadata...`);
|
||||||
@@ -35,7 +84,8 @@ export function useMetadata() {
|
|||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const data = await fetchSpotifyMetadata(url);
|
const timeout = urlType === "artist" ? 60 : 300;
|
||||||
|
const data = await fetchSpotifyMetadata(url, true, 1.0, timeout);
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
if ("playlist_info" in data) {
|
if ("playlist_info" in data) {
|
||||||
const playlistInfo = data.playlist_info;
|
const playlistInfo = data.playlist_info;
|
||||||
@@ -56,6 +106,7 @@ export function useMetadata() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
|
saveToHistory(url, data);
|
||||||
if ("track" in data) {
|
if ("track" in data) {
|
||||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||||
@@ -84,6 +135,17 @@ export function useMetadata() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const loadFromCache = (cachedData: string) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(cachedData);
|
||||||
|
setMetadata(data);
|
||||||
|
toast.success("Loaded from cache");
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to load from cache:", err);
|
||||||
|
toast.error("Failed to load from cache");
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleFetchMetadata = async (url: string) => {
|
const handleFetchMetadata = async (url: string) => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
logger.warning("empty url provided");
|
logger.warning("empty url provided");
|
||||||
@@ -97,43 +159,15 @@ export function useMetadata() {
|
|||||||
logger.debug("converted to discography url");
|
logger.debug("converted to discography url");
|
||||||
}
|
}
|
||||||
if (isArtistUrl) {
|
if (isArtistUrl) {
|
||||||
logger.info("artist url detected, showing timeout dialog");
|
logger.info("artist url detected");
|
||||||
setPendingUrl(urlToFetch);
|
|
||||||
setPendingArtistName(null);
|
setPendingArtistName(null);
|
||||||
setShowTimeoutDialog(true);
|
await fetchMetadataDirectly(urlToFetch);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await fetchMetadataDirectly(urlToFetch);
|
await fetchMetadataDirectly(urlToFetch);
|
||||||
}
|
}
|
||||||
return urlToFetch;
|
return urlToFetch;
|
||||||
};
|
};
|
||||||
const handleConfirmFetch = async () => {
|
|
||||||
setShowTimeoutDialog(false);
|
|
||||||
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
|
||||||
logger.debug(`url: ${pendingUrl}`);
|
|
||||||
setLoading(true);
|
|
||||||
setMetadata(null);
|
|
||||||
try {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
||||||
setMetadata(data);
|
|
||||||
if ("artist_info" in data) {
|
|
||||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
|
||||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
|
||||||
}
|
|
||||||
logger.info(`fetch completed in ${elapsed}s`);
|
|
||||||
toast.success("Metadata fetched successfully");
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
|
||||||
logger.error(`fetch failed: ${errorMsg}`);
|
|
||||||
toast.error(errorMsg);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleAlbumClick = (album: {
|
const handleAlbumClick = (album: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -150,9 +184,8 @@ export function useMetadata() {
|
|||||||
}) => {
|
}) => {
|
||||||
logger.debug(`artist clicked: ${artist.name}`);
|
logger.debug(`artist clicked: ${artist.name}`);
|
||||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
setPendingUrl(artistUrl);
|
|
||||||
setPendingArtistName(artist.name);
|
setPendingArtistName(artist.name);
|
||||||
setShowTimeoutDialog(true);
|
await fetchMetadataDirectly(artistUrl);
|
||||||
return artistUrl;
|
return artistUrl;
|
||||||
};
|
};
|
||||||
const handleConfirmAlbumFetch = async () => {
|
const handleConfirmAlbumFetch = async () => {
|
||||||
@@ -179,6 +212,7 @@ export function useMetadata() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
|
saveToHistory(albumUrl, data);
|
||||||
if ("album_info" in data) {
|
if ("album_info" in data) {
|
||||||
logger.success(`fetched album: ${data.album_info.name}`);
|
logger.success(`fetched album: ${data.album_info.name}`);
|
||||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||||
@@ -200,18 +234,15 @@ export function useMetadata() {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
metadata,
|
metadata,
|
||||||
showTimeoutDialog,
|
|
||||||
setShowTimeoutDialog,
|
|
||||||
timeoutValue,
|
|
||||||
setTimeoutValue,
|
|
||||||
showAlbumDialog,
|
showAlbumDialog,
|
||||||
setShowAlbumDialog,
|
setShowAlbumDialog,
|
||||||
selectedAlbum,
|
selectedAlbum,
|
||||||
pendingArtistName,
|
pendingArtistName,
|
||||||
handleFetchMetadata,
|
handleFetchMetadata,
|
||||||
handleConfirmFetch,
|
|
||||||
handleAlbumClick,
|
handleAlbumClick,
|
||||||
handleConfirmAlbumFetch,
|
handleConfirmAlbumFetch,
|
||||||
handleArtistClick,
|
handleArtistClick,
|
||||||
|
loadFromCache,
|
||||||
|
resetMetadata: () => setMetadata(null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
export function usePreview() {
|
export function usePreview() {
|
||||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
||||||
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
|
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [currentAudio]);
|
||||||
const playPreview = async (trackId: string, trackName: string) => {
|
const playPreview = async (trackId: string, trackName: string) => {
|
||||||
try {
|
try {
|
||||||
if (playingTrack === trackId && currentAudio) {
|
if (playingTrack === trackId && currentAudio) {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
export function useTypingEffect(texts: string[], typingSpeed: number = 50, deletingSpeed: number = 50, pauseDuration: number = 1500) {
|
||||||
|
const [displayedText, setDisplayedText] = useState('');
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [textIndex, setTextIndex] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayedText("");
|
||||||
|
setIsDeleting(false);
|
||||||
|
setTextIndex(0);
|
||||||
|
}, [texts]);
|
||||||
|
useEffect(() => {
|
||||||
|
const currentText = texts[textIndex % texts.length];
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
if (isDeleting) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
setDisplayedText((prev) => prev.substring(0, prev.length - 1));
|
||||||
|
}, deletingSpeed);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
setDisplayedText((prev) => currentText.substring(0, prev.length + 1));
|
||||||
|
}, typingSpeed);
|
||||||
|
}
|
||||||
|
if (!isDeleting && displayedText === currentText) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => setIsDeleting(true), pauseDuration);
|
||||||
|
}
|
||||||
|
else if (isDeleting && displayedText === '') {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setTextIndex((prev) => (prev + 1) % texts.length);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [displayedText, isDeleting, textIndex, texts, typingSpeed, deletingSpeed, pauseDuration]);
|
||||||
|
return displayedText;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export interface Settings {
|
|||||||
amazonQuality: "original";
|
amazonQuality: "original";
|
||||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
|
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
|
||||||
autoQuality: "16" | "24";
|
autoQuality: "16" | "24";
|
||||||
|
allowFallback: boolean;
|
||||||
}
|
}
|
||||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -99,7 +100,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
qobuzQuality: "6",
|
qobuzQuality: "6",
|
||||||
amazonQuality: "original",
|
amazonQuality: "original",
|
||||||
autoOrder: "tidal-qobuz-amazon",
|
autoOrder: "tidal-qobuz-amazon",
|
||||||
autoQuality: "16"
|
autoQuality: "16",
|
||||||
|
allowFallback: true
|
||||||
};
|
};
|
||||||
export const FONT_OPTIONS: {
|
export const FONT_OPTIONS: {
|
||||||
value: FontFamily;
|
value: FontFamily;
|
||||||
@@ -206,6 +208,9 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
if (!('autoQuality' in parsed)) {
|
if (!('autoQuality' in parsed)) {
|
||||||
parsed.autoQuality = "16";
|
parsed.autoQuality = "16";
|
||||||
}
|
}
|
||||||
|
if (!('allowFallback' in parsed)) {
|
||||||
|
parsed.allowFallback = true;
|
||||||
|
}
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +287,9 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
if (!('autoQuality' in parsed)) {
|
if (!('autoQuality' in parsed)) {
|
||||||
parsed.autoQuality = "16";
|
parsed.autoQuality = "16";
|
||||||
}
|
}
|
||||||
|
if (!('allowFallback' in parsed)) {
|
||||||
|
parsed.allowFallback = true;
|
||||||
|
}
|
||||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
return cachedSettings!;
|
return cachedSettings!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface TrackMetadata {
|
|||||||
publisher?: string;
|
publisher?: string;
|
||||||
plays?: string;
|
plays?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
is_explicit?: boolean;
|
||||||
}
|
}
|
||||||
export interface TrackResponse {
|
export interface TrackResponse {
|
||||||
track: TrackMetadata;
|
track: TrackMetadata;
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
const wailsJsonPath = path.resolve(__dirname, "../wails.json");
|
||||||
|
const wailsJson = JSON.parse(fs.readFileSync(wailsJsonPath, "utf-8"));
|
||||||
|
const appVersion = wailsJson.info.productVersion;
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -9,4 +13,7 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.0.6",
|
"productVersion": "7.0.7",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user