Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25233349b9 | |||
| e04f6e4fdd | |||
| 24bcc56a8f | |||
| 45ad82bb66 | |||
| 13fcb5787d | |||
| 556e720574 | |||
| 791553bdc0 | |||
| 9361c608ca | |||
| 12729e2ca1 | |||
| b620112886 | |||
| cc1c80d367 | |||
| 63149c91a2 | |||
| 1e99d8b5c6 | |||
| b160d3c790 | |||
| d9cf5a5361 | |||
| 4f135f1153 | |||
| 4ee252f438 | |||
| 2fc08de757 | |||
| 6e3ca48d3f | |||
| 46a7777698 | |||
| 0f2174bf80 | |||
| 36fb34dc63 | |||
| 7f859db173 | |||
| 6e66105481 |
@@ -0,0 +1,2 @@
|
|||||||
|
github: afkarxyz
|
||||||
|
ko_fi: afkarxyz
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||

|
<!--  -->
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -20,22 +20,63 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Other projects
|
## Other projects
|
||||||
|
|
||||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next)
|
||||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
|
|
||||||
|
Get Spotify tracks in Hi-Res lossless FLACs — no account required.
|
||||||
|
|
||||||
|
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
[](https://ko-fi.com/afkarxyz)
|
## FAQ
|
||||||
|
|
||||||
> Every coffee helps me keep going
|
### Is this software free?
|
||||||
|
|
||||||
|
_Yes. This software is completely free.
|
||||||
|
You do not need an account, login, or subscription.
|
||||||
|
All you need is an internet connection._
|
||||||
|
|
||||||
|
### Can using this software get my Spotify account suspended or banned?
|
||||||
|
|
||||||
|
_No.
|
||||||
|
This software has no connection to your Spotify account.
|
||||||
|
Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication._
|
||||||
|
|
||||||
|
### Where does the audio come from?
|
||||||
|
|
||||||
|
_The audio is fetched using third-party APIs._
|
||||||
|
|
||||||
|
### Why does metadata fetching sometimes fail?
|
||||||
|
|
||||||
|
_This usually happens because your IP address has been rate-limited.
|
||||||
|
You can wait and try again later, or use a VPN to bypass the rate limit._
|
||||||
|
|
||||||
|
### Why does Windows Defender or antivirus flag or delete the file?
|
||||||
|
|
||||||
|
_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._
|
||||||
|
|
||||||
|
### Want to support the project?
|
||||||
|
|
||||||
|
_If this software is useful and brings you value,
|
||||||
|
consider supporting the project by buying me a coffee.
|
||||||
|
Your support helps keep development going._
|
||||||
|
|
||||||
|
[](https://ko-fi.com/afkarxyz)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
@@ -44,12 +85,18 @@ 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.
|
||||||
|
|
||||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||||
|
|
||||||
|
## API Credits
|
||||||
|
|
||||||
|
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api)
|
||||||
|
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev/)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"spotiflac/backend"
|
"spotiflac/backend"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var isrcRegex = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$`)
|
||||||
|
|
||||||
|
func isValidISRC(isrc string) bool {
|
||||||
|
return isrcRegex.MatchString(isrc)
|
||||||
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
@@ -22,6 +33,14 @@ func NewApp() *App {
|
|||||||
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
|
if err := backend.InitHistoryDB("SpotiFLAC"); err != nil {
|
||||||
|
fmt.Printf("Failed to init history DB: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
|
backend.CloseHistoryDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpotifyMetadataRequest struct {
|
type SpotifyMetadataRequest struct {
|
||||||
@@ -60,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 {
|
||||||
@@ -71,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
|
||||||
}
|
}
|
||||||
@@ -182,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 == "" {
|
||||||
@@ -262,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 {
|
||||||
@@ -282,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.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{
|
||||||
@@ -291,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.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{
|
||||||
@@ -307,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{
|
||||||
@@ -322,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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +358,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deezerISRC := req.ISRC
|
deezerISRC := req.ISRC
|
||||||
|
|
||||||
|
if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) {
|
||||||
|
deezerISRC = ""
|
||||||
|
}
|
||||||
|
|
||||||
if deezerISRC == "" && req.SpotifyID != "" {
|
if deezerISRC == "" && req.SpotifyID != "" {
|
||||||
|
|
||||||
songlinkClient := backend.NewSongLinkClient()
|
songlinkClient := backend.NewSongLinkClient()
|
||||||
@@ -360,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{
|
||||||
@@ -370,6 +397,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err))
|
||||||
|
|
||||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||||
|
|
||||||
@@ -456,6 +484,46 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
|
|
||||||
backend.CompleteDownloadItem(itemID, filename, 0)
|
backend.CompleteDownloadItem(itemID, filename, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func(fPath, track, artist, album, sID, cover, format string) {
|
||||||
|
quality := "Unknown"
|
||||||
|
durationStr := "--:--"
|
||||||
|
|
||||||
|
meta, err := backend.GetTrackMetadata(fPath)
|
||||||
|
if err == nil && meta != nil {
|
||||||
|
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
|
||||||
|
d := int(meta.Duration)
|
||||||
|
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
item := backend.HistoryItem{
|
||||||
|
SpotifyID: sID,
|
||||||
|
Title: track,
|
||||||
|
Artists: artist,
|
||||||
|
Album: album,
|
||||||
|
DurationStr: durationStr,
|
||||||
|
CoverURL: cover,
|
||||||
|
Quality: quality,
|
||||||
|
Format: format,
|
||||||
|
Path: fPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Format == "" || item.Format == "LOSSLESS" {
|
||||||
|
ext := filepath.Ext(fPath)
|
||||||
|
if len(ext) > 1 {
|
||||||
|
item.Format = strings.ToUpper(ext[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item.Format {
|
||||||
|
case "6", "7", "27":
|
||||||
|
item.Format = "FLAC"
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.AddHistoryItem(item, "SpotiFLAC")
|
||||||
|
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -529,6 +597,38 @@ func (a *App) Quit() {
|
|||||||
panic("quit")
|
panic("quit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) GetDownloadHistory() ([]backend.HistoryItem, error) {
|
||||||
|
return backend.GetHistoryItems("SpotiFLAC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ClearDownloadHistory() error {
|
||||||
|
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")
|
||||||
@@ -828,16 +928,19 @@ type DownloadFFmpegResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "starting")
|
||||||
err := backend.DownloadFFmpeg(func(progress int) {
|
err := backend.DownloadFFmpeg(func(progress int) {
|
||||||
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress)
|
runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed")
|
||||||
return DownloadFFmpegResponse{
|
return DownloadFFmpegResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed")
|
||||||
return DownloadFFmpegResponse{
|
return DownloadFFmpegResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "FFmpeg installed successfully",
|
Message: "FFmpeg installed successfully",
|
||||||
@@ -917,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 {
|
||||||
@@ -956,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 {
|
||||||
@@ -1018,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,
|
||||||
@@ -1026,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
|
||||||
@@ -1049,3 +1181,67 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
|||||||
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
||||||
backend.SkipDownloadItem(itemID, filePath)
|
backend.SkipDownloadItem(itemID, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) GetPreviewURL(trackID string) (string, error) {
|
||||||
|
return backend.GetPreviewURL(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetConfigPath() (string, error) {
|
||||||
|
dir, err := backend.GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "config.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveSettings(settings map[string]interface{}) error {
|
||||||
|
configPath, err := a.GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoadSettings() (map[string]interface{}, error) {
|
||||||
|
configPath, err := a.GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CheckFFmpegInstalled() (bool, error) {
|
||||||
|
return backend.IsFFmpegInstalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetOSInfo() (string, error) {
|
||||||
|
return backend.GetOSInfo()
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,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 {
|
||||||
@@ -29,19 +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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
@@ -49,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)
|
||||||
@@ -175,189 +107,76 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
return amazonURL, nil
|
return amazonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) {
|
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
||||||
var lastError error
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
for _, region := range a.regions {
|
if err != nil {
|
||||||
fmt.Printf("\nTrying region: %s...\n", region)
|
return "", err
|
||||||
|
|
||||||
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)
|
fmt.Printf("Fetching from AfkarXYZ...\n")
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
var apiResp AfkarXYZResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
out, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
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)
|
||||||
|
_, err = io.Copy(pw, dlResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(filePath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, 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) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
||||||
|
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -366,7 +185,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -377,7 +196,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
|||||||
|
|
||||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||||
|
|
||||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -492,12 +311,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
|||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, 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, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,3 +162,46 @@ func GetFileSize(filepath string) (int64, error) {
|
|||||||
}
|
}
|
||||||
return info.Size(), nil
|
return info.Size(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||||
|
if !fileExists(filepath) {
|
||||||
|
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := flac.ParseFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AnalysisResult{
|
||||||
|
FilePath: filepath,
|
||||||
|
FileSize: fileInfo.Size(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.Meta) > 0 {
|
||||||
|
streamInfo := f.Meta[0]
|
||||||
|
if streamInfo.Type == flac.StreamInfo {
|
||||||
|
data := streamInfo.Data
|
||||||
|
if len(data) >= 18 {
|
||||||
|
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||||
|
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
||||||
|
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||||
|
uint64(data[14])<<24 |
|
||||||
|
uint64(data[15])<<16 |
|
||||||
|
uint64(data[16])<<8 |
|
||||||
|
uint64(data[17])
|
||||||
|
|
||||||
|
if result.SampleRate > 0 {
|
||||||
|
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
spotifySize300 = "ab67616d00001e02"
|
||||||
spotifySize640 = "ab67616d0000b273"
|
spotifySize640 = "ab67616d0000b273"
|
||||||
spotifySizeMax = "ab67616d000082c1"
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
)
|
)
|
||||||
@@ -118,21 +119,30 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
|||||||
return filename + ".cover.jpg"
|
return filename + ".cover.jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize640) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
}
|
}
|
||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||||
|
|
||||||
|
mediumURL := convertSmallToMedium(imageURL)
|
||||||
|
if strings.Contains(mediumURL, spotifySize640) {
|
||||||
|
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
|
||||||
|
}
|
||||||
|
return mediumURL
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return fmt.Errorf("cover URL is required")
|
return fmt.Errorf("cover URL is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := coverURL
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
if embedMaxQualityCover {
|
if embedMaxQualityCover {
|
||||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
downloadURL = c.getMaxResolutionURL(downloadURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Get(downloadURL)
|
resp, err := c.httpClient.Get(downloadURL)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HistoryItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
DurationStr string `json:"duration_str"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var historyDB *bolt.DB
|
||||||
|
|
||||||
|
const (
|
||||||
|
historyBucket = "DownloadHistory"
|
||||||
|
maxHistory = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitHistoryDB(appName string) error {
|
||||||
|
|
||||||
|
appDir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(appDir); os.IsNotExist(err) {
|
||||||
|
os.MkdirAll(appDir, 0755)
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join(appDir, "history.db")
|
||||||
|
|
||||||
|
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Update(func(tx *bolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
db.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
historyDB = db
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloseHistoryDB() {
|
||||||
|
if historyDB != nil {
|
||||||
|
historyDB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddHistoryItem(item HistoryItem, 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(historyBucket))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, _ := b.NextSequence()
|
||||||
|
|
||||||
|
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 GetHistoryItems(appName string) ([]HistoryItem, error) {
|
||||||
|
if historyDB == nil {
|
||||||
|
if err := InitHistoryDB(appName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var items []HistoryItem
|
||||||
|
err := historyDB.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(historyBucket))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := b.Cursor()
|
||||||
|
|
||||||
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
|
var item HistoryItem
|
||||||
|
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 ClearHistory(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(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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -403,6 +403,25 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
outputDir = NormalizePath(outputDir)
|
outputDir = NormalizePath(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safeArtist := sanitizeFilename(req.AlbumArtist)
|
||||||
|
if safeArtist == "" {
|
||||||
|
safeArtist = sanitizeFilename(req.ArtistName)
|
||||||
|
}
|
||||||
|
safeAlbum := sanitizeFilename(req.AlbumName)
|
||||||
|
|
||||||
|
if safeArtist != "" && safeAlbum != "" {
|
||||||
|
artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum)
|
||||||
|
if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() {
|
||||||
|
outputDir = artistAlbumPath
|
||||||
|
} else {
|
||||||
|
|
||||||
|
artistPath := filepath.Join(outputDir, safeArtist)
|
||||||
|
if info, err := os.Stat(artistPath); err == nil && info.IsDir() {
|
||||||
|
outputDir = artistPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
@@ -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,81 +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, 27=Hi-Res\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("Qobuz API URL: %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...")
|
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}
|
||||||
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("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 error response: %s\n", string(body))
|
|
||||||
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 "", 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 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")
|
if err == nil {
|
||||||
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Got download URL from fallback API\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 {
|
||||||
@@ -311,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 != "." {
|
||||||
@@ -339,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
|
||||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
|
||||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
|
||||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
|
||||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
|
||||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
|
||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
|
||||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
|
||||||
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
|
||||||
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
|
||||||
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
|
||||||
'っ': "",
|
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
|
||||||
}
|
|
||||||
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
|
||||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
|
||||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
|
||||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
|
||||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
|
||||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
|
||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
|
||||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
|
||||||
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
|
||||||
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
|
||||||
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
|
||||||
'ッ': "",
|
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
|
||||||
|
|
||||||
'ー': "",
|
|
||||||
'ヴ': "vu",
|
|
||||||
}
|
|
||||||
|
|
||||||
var combinationHiragana = map[string]string{
|
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
|
||||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
|
||||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
|
||||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
|
||||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
|
||||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
|
||||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
|
||||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
|
||||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
|
||||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
|
||||||
}
|
|
||||||
|
|
||||||
var combinationKatakana = map[string]string{
|
|
||||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
|
||||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
|
||||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
|
||||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
|
||||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
|
||||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
|
||||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
|
||||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
|
||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
|
||||||
|
|
||||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
|
||||||
}
|
|
||||||
|
|
||||||
func ContainsJapanese(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHiragana(r rune) bool {
|
|
||||||
return r >= 0x3040 && r <= 0x309F
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKatakana(r rune) bool {
|
|
||||||
return r >= 0x30A0 && r <= 0x30FF
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
|
||||||
return (r >= 0x4E00 && r <= 0x9FFF) ||
|
|
||||||
(r >= 0x3400 && r <= 0x4DBF)
|
|
||||||
}
|
|
||||||
|
|
||||||
func JapaneseToRomaji(text string) string {
|
|
||||||
if !ContainsJapanese(text) {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
runes := []rune(text)
|
|
||||||
i := 0
|
|
||||||
|
|
||||||
for i < len(runes) {
|
|
||||||
|
|
||||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
|
||||||
nextRomaji := ""
|
|
||||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
}
|
|
||||||
if len(nextRomaji) > 0 {
|
|
||||||
result.WriteByte(nextRomaji[0])
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if i < len(runes)-1 {
|
|
||||||
combo := string(runes[i : i+2])
|
|
||||||
if romaji, ok := combinationHiragana[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if romaji, ok := combinationKatakana[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r := runes[i]
|
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
} else if isKanji(r) {
|
|
||||||
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else {
|
|
||||||
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
|
||||||
|
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
|
||||||
artistRomaji := JapaneseToRomaji(artistName)
|
|
||||||
|
|
||||||
trackClean := cleanSearchQuery(trackRomaji)
|
|
||||||
artistClean := cleanSearchQuery(artistRomaji)
|
|
||||||
|
|
||||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanSearchQuery(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else if r == '-' || r == '\'' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(result.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanToASCII(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
|
||||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else if r == ',' || r == '.' {
|
|
||||||
|
|
||||||
result.WriteRune(' ')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
|
||||||
return strings.TrimSpace(cleaned)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetOSInfo() (string, error) {
|
||||||
|
osType := runtime.GOOS
|
||||||
|
arch := runtime.GOARCH
|
||||||
|
|
||||||
|
switch osType {
|
||||||
|
case "darwin":
|
||||||
|
out, err := exec.Command("sw_vers", "-productVersion").Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("macOS %s", arch), nil
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(string(out))
|
||||||
|
return fmt.Sprintf("macOS %s (%s)", version, arch), nil
|
||||||
|
|
||||||
|
case "linux":
|
||||||
|
out, err := exec.Command("cat", "/etc/os-release").Output()
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
||||||
|
name := strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
||||||
|
return fmt.Sprintf("%s (%s)", name, arch), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Linux %s", arch), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s %s", osType, arch), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetOSInfo() (string, error) {
|
||||||
|
arch := runtime.GOARCH
|
||||||
|
|
||||||
|
cmd := exec.Command("wmic", "os", "get", "Caption,Version", "/value")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
cmdVer := exec.Command("cmd", "/c", "ver")
|
||||||
|
cmdVer.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
outVer, errVer := cmdVer.Output()
|
||||||
|
if errVer != nil {
|
||||||
|
return fmt.Sprintf("Windows %s", arch), nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(outVer)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
var caption, version string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "Caption=") {
|
||||||
|
caption = strings.TrimPrefix(line, "Caption=")
|
||||||
|
} else if strings.HasPrefix(line, "Version=") {
|
||||||
|
version = strings.TrimPrefix(line, "Version=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if caption != "" && version != "" {
|
||||||
|
return fmt.Sprintf("%s (%s, %s)", caption, version, arch), nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
@@ -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,45 +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 TidalSearchResponse struct {
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
TotalNumberOfItems int `json:"totalNumberOfItems"`
|
|
||||||
Items []TidalTrack `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -77,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"`
|
||||||
@@ -90,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()
|
||||||
@@ -115,257 +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) SearchTracks(query string) (*TidalSearchResponse, error) {
|
|
||||||
return t.SearchTracksWithLimit(query, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*TidalSearchResponse, error) {
|
|
||||||
token, err := t.GetAccessToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=%d&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query), limit)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, 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("search failed: HTTP %d - %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result TidalSearchResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string, expectedDuration int) (*TidalTrack, error) {
|
|
||||||
|
|
||||||
queries := []string{}
|
|
||||||
|
|
||||||
if artistName != "" && trackName != "" {
|
|
||||||
queries = append(queries, artistName+" "+trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackName != "" {
|
|
||||||
queries = append(queries, trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
|
||||||
|
|
||||||
romajiTrack := JapaneseToRomaji(trackName)
|
|
||||||
romajiArtist := JapaneseToRomaji(artistName)
|
|
||||||
|
|
||||||
cleanRomajiTrack := cleanToASCII(romajiTrack)
|
|
||||||
cleanRomajiArtist := cleanToASCII(romajiArtist)
|
|
||||||
|
|
||||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
|
||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
|
||||||
if !containsQuery(queries, romajiQuery) {
|
|
||||||
queries = append(queries, romajiQuery)
|
|
||||||
fmt.Printf("Japanese detected, adding romaji query: %s\n", romajiQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
|
||||||
if !containsQuery(queries, cleanRomajiTrack) {
|
|
||||||
queries = append(queries, cleanRomajiTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if artistName != "" && cleanRomajiTrack != "" {
|
|
||||||
partialQuery := artistName + " " + cleanRomajiTrack
|
|
||||||
if !containsQuery(queries, partialQuery) {
|
|
||||||
queries = append(queries, partialQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if artistName != "" {
|
|
||||||
artistOnly := cleanToASCII(JapaneseToRomaji(artistName))
|
|
||||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
|
||||||
queries = append(queries, artistOnly)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allTracks []TidalTrack
|
|
||||||
searchedQueries := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, query := range queries {
|
|
||||||
cleanQuery := strings.TrimSpace(query)
|
|
||||||
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
searchedQueries[cleanQuery] = true
|
|
||||||
|
|
||||||
fmt.Printf("Searching Tidal for: %s\n", cleanQuery)
|
|
||||||
|
|
||||||
result, err := t.SearchTracksWithLimit(cleanQuery, 100)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Search error for '%s': %v\n", cleanQuery, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Items) > 0 {
|
|
||||||
fmt.Printf("Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
|
||||||
allTracks = append(allTracks, result.Items...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allTracks) == 0 {
|
|
||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestMatch *TidalTrack
|
|
||||||
if expectedDuration > 0 {
|
|
||||||
tolerance := 3
|
|
||||||
var durationMatches []*TidalTrack
|
|
||||||
|
|
||||||
for i := range allTracks {
|
|
||||||
track := &allTracks[i]
|
|
||||||
durationDiff := track.Duration - expectedDuration
|
|
||||||
if durationDiff < 0 {
|
|
||||||
durationDiff = -durationDiff
|
|
||||||
}
|
|
||||||
if durationDiff <= tolerance {
|
|
||||||
durationMatches = append(durationMatches, track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(durationMatches) > 0 {
|
|
||||||
|
|
||||||
bestMatch = durationMatches[0]
|
|
||||||
for _, track := range durationMatches {
|
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
|
||||||
if tag == "HIRES_LOSSLESS" {
|
|
||||||
bestMatch = track
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("Found via duration match: %s - %s (%s)\n",
|
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
|
||||||
return bestMatch, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bestMatch = &allTracks[0]
|
|
||||||
for i := range allTracks {
|
|
||||||
track := &allTracks[i]
|
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
|
||||||
if tag == "HIRES_LOSSLESS" {
|
|
||||||
bestMatch = track
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bestMatch != &allTracks[0] {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
|
||||||
|
|
||||||
return bestMatch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsQuery(queries []string, query string) bool {
|
|
||||||
for _, q := range queries {
|
|
||||||
if q == query {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -422,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...")
|
||||||
|
|
||||||
@@ -515,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:") {
|
||||||
@@ -713,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)
|
||||||
@@ -727,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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,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 {
|
||||||
@@ -753,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)
|
||||||
@@ -811,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)
|
||||||
@@ -830,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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,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 {
|
||||||
@@ -856,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)
|
||||||
@@ -915,38 +595,45 @@ 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 {
|
||||||
|
Initialization string `xml:"initialization,attr"`
|
||||||
|
Media string `xml:"media,attr"`
|
||||||
|
Timeline struct {
|
||||||
|
Segments []struct {
|
||||||
|
Duration int64 `xml:"d,attr"`
|
||||||
|
Repeat int `xml:"r,attr"`
|
||||||
|
} `xml:"S"`
|
||||||
|
} `xml:"SegmentTimeline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MPD struct {
|
type MPD struct {
|
||||||
XMLName xml.Name `xml:"MPD"`
|
XMLName xml.Name `xml:"MPD"`
|
||||||
Period struct {
|
Period struct {
|
||||||
AdaptationSet struct {
|
AdaptationSets []struct {
|
||||||
Representation struct {
|
MimeType string `xml:"mimeType,attr"`
|
||||||
SegmentTemplate struct {
|
Codecs string `xml:"codecs,attr"`
|
||||||
Initialization string `xml:"initialization,attr"`
|
Representations []struct {
|
||||||
Media string `xml:"media,attr"`
|
ID string `xml:"id,attr"`
|
||||||
Timeline struct {
|
Codecs string `xml:"codecs,attr"`
|
||||||
Segments []struct {
|
Bandwidth int `xml:"bandwidth,attr"`
|
||||||
Duration int `xml:"d,attr"`
|
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||||
Repeat int `xml:"r,attr"`
|
|
||||||
} `xml:"S"`
|
|
||||||
} `xml:"SegmentTimeline"`
|
|
||||||
} `xml:"SegmentTemplate"`
|
|
||||||
} `xml:"Representation"`
|
} `xml:"Representation"`
|
||||||
|
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||||
} `xml:"AdaptationSet"`
|
} `xml:"AdaptationSet"`
|
||||||
} `xml:"Period"`
|
} `xml:"Period"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||||
|
|
||||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
||||||
@@ -954,8 +641,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
manifestStr := string(manifestBytes)
|
manifestStr := string(manifestBytes)
|
||||||
|
|
||||||
if strings.HasPrefix(manifestStr, "{") {
|
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
|
||||||
|
|
||||||
var btsManifest TidalBTSManifest
|
var btsManifest TidalBTSManifest
|
||||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
||||||
@@ -972,25 +658,78 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
fmt.Println("Manifest: DASH format")
|
fmt.Println("Manifest: DASH format")
|
||||||
|
|
||||||
var mpd MPD
|
var mpd MPD
|
||||||
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
var segTemplate *SegmentTemplate
|
||||||
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
|
||||||
|
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
||||||
|
var selectedBandwidth int
|
||||||
|
var selectedCodecs string
|
||||||
|
|
||||||
|
for _, as := range mpd.Period.AdaptationSets {
|
||||||
|
|
||||||
|
if as.SegmentTemplate != nil {
|
||||||
|
|
||||||
|
if segTemplate == nil {
|
||||||
|
segTemplate = as.SegmentTemplate
|
||||||
|
selectedCodecs = as.Codecs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rep := range as.Representations {
|
||||||
|
if rep.SegmentTemplate != nil {
|
||||||
|
if rep.Bandwidth > selectedBandwidth {
|
||||||
|
selectedBandwidth = rep.Bandwidth
|
||||||
|
segTemplate = rep.SegmentTemplate
|
||||||
|
|
||||||
|
if rep.Codecs != "" {
|
||||||
|
selectedCodecs = rep.Codecs
|
||||||
|
} else {
|
||||||
|
selectedCodecs = as.Codecs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedBandwidth > 0 {
|
||||||
|
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate
|
var mediaTemplate string
|
||||||
initURL = segTemplate.Initialization
|
segmentCount := 0
|
||||||
mediaTemplate := segTemplate.Media
|
|
||||||
|
|
||||||
if initURL == "" || mediaTemplate == "" {
|
if segTemplate != nil {
|
||||||
|
initURL = segTemplate.Initialization
|
||||||
|
mediaTemplate = segTemplate.Media
|
||||||
|
|
||||||
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
for _, seg := range segTemplate.Timeline.Segments {
|
||||||
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
segmentCount += seg.Repeat + 1
|
||||||
|
|
||||||
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
|
||||||
initURL = match[1]
|
|
||||||
}
|
}
|
||||||
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
}
|
||||||
mediaTemplate = match[1]
|
|
||||||
|
if segmentCount > 0 && initURL != "" && mediaTemplate != "" {
|
||||||
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||||
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||||
|
|
||||||
|
fmt.Printf("Parsed manifest via XML: %d segments\n", segmentCount)
|
||||||
|
|
||||||
|
for i := 1; i <= segmentCount; i++ {
|
||||||
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
|
return "", initURL, mediaURLs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Using regex fallback for DASH manifest...")
|
||||||
|
|
||||||
|
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
||||||
|
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
||||||
|
|
||||||
|
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||||
|
initURL = match[1]
|
||||||
|
}
|
||||||
|
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||||
|
mediaTemplate = match[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
if initURL == "" {
|
if initURL == "" {
|
||||||
@@ -1000,23 +739,26 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||||
|
|
||||||
segmentCount := 0
|
segmentCount = 0
|
||||||
for _, seg := range segTemplate.Timeline.Segments {
|
|
||||||
segmentCount += seg.Repeat + 1
|
segTagRe := regexp.MustCompile(`<S\s+[^>]*>`)
|
||||||
|
matches := segTagRe.FindAllString(manifestStr, -1)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
repeat := 0
|
||||||
|
rRe := regexp.MustCompile(`r="(\d+)"`)
|
||||||
|
if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 {
|
||||||
|
fmt.Sscanf(rMatch[1], "%d", &repeat)
|
||||||
|
}
|
||||||
|
segmentCount += repeat + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if segmentCount == 0 {
|
if segmentCount == 0 {
|
||||||
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`)
|
return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
||||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
|
||||||
for _, match := range matches {
|
|
||||||
repeat := 0
|
|
||||||
if len(match) > 1 && match[1] != "" {
|
|
||||||
fmt.Sscanf(match[1], "%d", &repeat)
|
|
||||||
}
|
|
||||||
segmentCount += repeat + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
||||||
|
|
||||||
for i := 1; i <= segmentCount; i++ {
|
for i := 1; i <= segmentCount; i++ {
|
||||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
@@ -1025,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
|
||||||
|
}
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js';
|
||||||
import globals from 'globals'
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint';
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]);
|
||||||
])
|
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<title>SpotiFLAC</title>
|
<link
|
||||||
</head>
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap"
|
||||||
<body>
|
rel="stylesheet">
|
||||||
<div id="root"></div>
|
<title>SpotiFLAC</title>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
</head>
|
||||||
</body>
|
|
||||||
</html>
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -17,9 +17,11 @@
|
|||||||
"@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",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.24.12",
|
"motion": "^12.26.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
@@ -37,8 +39,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.8",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.52.0",
|
"typescript-eslint": "^8.53.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
be90455e8d3a26cf5c12d4fa0779bc1a
|
629a5f17426ea4202a25837a341483dd
|
||||||
@@ -2,32 +2,23 @@ import sharp from 'sharp';
|
|||||||
import { readFileSync, mkdirSync } from 'fs';
|
import { readFileSync, mkdirSync } from 'fs';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const rootDir = join(__dirname, '..', '..');
|
const rootDir = join(__dirname, '..', '..');
|
||||||
|
|
||||||
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
|
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
|
||||||
const outputPath = join(rootDir, 'build', 'appicon.png');
|
const outputPath = join(rootDir, 'build', 'appicon.png');
|
||||||
|
|
||||||
async function generateIcon() {
|
async function generateIcon() {
|
||||||
try {
|
try {
|
||||||
// Ensure build directory exists
|
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
||||||
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
const svgBuffer = readFileSync(svgPath);
|
||||||
|
await sharp(svgBuffer)
|
||||||
// Read SVG
|
.resize(1024, 1024)
|
||||||
const svgBuffer = readFileSync(svgPath);
|
.png()
|
||||||
|
.toFile(outputPath);
|
||||||
// Convert SVG to PNG (1024x1024 for Wails)
|
console.log('✓ Icon generated:', outputPath);
|
||||||
await sharp(svgBuffer)
|
}
|
||||||
.resize(1024, 1024)
|
catch (error) {
|
||||||
.png()
|
console.error('✗ Failed to generate icon:', error.message);
|
||||||
.toFile(outputPath);
|
process.exit(1);
|
||||||
|
}
|
||||||
console.log('✓ Icon generated:', outputPath);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('✗ Failed to generate icon:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generateIcon();
|
generateIcon();
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } 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, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||||
import { applyTheme } from "@/lib/themes";
|
import { applyTheme } from "@/lib/themes";
|
||||||
import { OpenFolder } from "../wailsjs/go/main/App";
|
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App";
|
||||||
|
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { TitleBar } from "@/components/TitleBar";
|
import { TitleBar } from "@/components/TitleBar";
|
||||||
import { Sidebar, type PageType } from "@/components/Sidebar";
|
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||||
@@ -24,6 +23,8 @@ import { AudioConverterPage } from "@/components/AudioConverterPage";
|
|||||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||||
import { SettingsPage } from "@/components/SettingsPage";
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
|
import { AboutPage } from "@/components/AboutPage";
|
||||||
|
import { HistoryPage } from "@/components/HistoryPage";
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
import { useDownload } from "@/hooks/useDownload";
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
@@ -31,6 +32,7 @@ import { useLyrics } from "@/hooks/useLyrics";
|
|||||||
import { useCover } from "@/hooks/useCover";
|
import { useCover } from "@/hooks/useCover";
|
||||||
import { useAvailability } from "@/hooks/useAvailability";
|
import { useAvailability } from "@/hooks/useAvailability";
|
||||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||||
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||||
const MAX_HISTORY = 5;
|
const MAX_HISTORY = 5;
|
||||||
function App() {
|
function App() {
|
||||||
@@ -44,31 +46,59 @@ 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.1";
|
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();
|
||||||
const availability = useAvailability();
|
const availability = useAvailability();
|
||||||
const downloadQueue = useDownloadQueueDialog();
|
const downloadQueue = useDownloadQueueDialog();
|
||||||
|
const downloadProgress = useDownloadProgress();
|
||||||
|
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null);
|
||||||
|
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
|
||||||
|
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
|
||||||
|
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const savedSettings = getSettings();
|
||||||
|
if (savedSettings) {
|
||||||
|
applyThemeMode(savedSettings.themeMode);
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
applyFont(savedSettings.fontFamily);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initSettings = async () => {
|
const initSettings = async () => {
|
||||||
const settings = getSettings();
|
const settings = await loadSettings();
|
||||||
applyThemeMode(settings.themeMode);
|
applyThemeMode(settings.themeMode);
|
||||||
applyTheme(settings.theme);
|
applyTheme(settings.theme);
|
||||||
applyFont(settings.fontFamily);
|
applyFont(settings.fontFamily);
|
||||||
if (!settings.downloadPath) {
|
if (!settings.downloadPath) {
|
||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
saveSettings(settingsWithDefaults);
|
await saveSettings(settingsWithDefaults);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
initSettings();
|
initSettings();
|
||||||
|
const checkFFmpeg = async () => {
|
||||||
|
try {
|
||||||
|
const installed = await CheckFFmpegInstalled();
|
||||||
|
setIsFFmpegInstalled(installed);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to check FFmpeg:", err);
|
||||||
|
setIsFFmpegInstalled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkFFmpeg();
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
const currentSettings = getSettings();
|
const currentSettings = getSettings();
|
||||||
@@ -129,6 +159,44 @@ function App() {
|
|||||||
console.error("Failed to load history:", err);
|
console.error("Failed to load history:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleInstallFFmpeg = async () => {
|
||||||
|
setIsInstallingFFmpeg(true);
|
||||||
|
setFfmpegInstallProgress(0);
|
||||||
|
setFfmpegInstallStatus("starting");
|
||||||
|
try {
|
||||||
|
EventsOn("ffmpeg:progress", (progress: number) => {
|
||||||
|
setFfmpegInstallProgress(progress);
|
||||||
|
if (progress >= 100) {
|
||||||
|
setFfmpegInstallStatus("extracting");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setFfmpegInstallStatus("downloading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
EventsOn("ffmpeg:status", (status: string) => {
|
||||||
|
setFfmpegInstallStatus(status);
|
||||||
|
});
|
||||||
|
const response = await DownloadFFmpeg();
|
||||||
|
EventsOff("ffmpeg:progress");
|
||||||
|
EventsOff("ffmpeg:status");
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("FFmpeg installed successfully!");
|
||||||
|
setIsFFmpegInstalled(true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(`Failed to install FFmpeg: ${response.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Error installing FFmpeg:", error);
|
||||||
|
toast.error(`Error during FFmpeg installation: ${error}`);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsInstallingFFmpeg(false);
|
||||||
|
setFfmpegInstallProgress(0);
|
||||||
|
setFfmpegInstallStatus("");
|
||||||
|
}
|
||||||
|
};
|
||||||
const saveHistory = (history: HistoryItem[]) => {
|
const saveHistory = (history: HistoryItem[]) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||||
@@ -190,7 +258,7 @@ function App() {
|
|||||||
url: spotifyUrl,
|
url: spotifyUrl,
|
||||||
type: "album",
|
type: "album",
|
||||||
name: album_info.name,
|
name: album_info.name,
|
||||||
artist: `${album_info.total_tracks} tracks`,
|
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
|
||||||
image: album_info.images,
|
image: album_info.images,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -200,7 +268,7 @@ function App() {
|
|||||||
url: spotifyUrl,
|
url: spotifyUrl,
|
||||||
type: "playlist",
|
type: "playlist",
|
||||||
name: playlist_info.owner.name,
|
name: playlist_info.owner.name,
|
||||||
artist: `${playlist_info.tracks.total} tracks`,
|
artist: `${playlist_info.tracks.total.toLocaleString()} tracks`,
|
||||||
image: playlist_info.cover || playlist_info.owner.images || "",
|
image: playlist_info.cover || playlist_info.owner.images || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -210,7 +278,7 @@ function App() {
|
|||||||
url: spotifyUrl,
|
url: spotifyUrl,
|
||||||
type: "artist",
|
type: "artist",
|
||||||
name: artist_info.name,
|
name: artist_info.name,
|
||||||
artist: `${artist_info.total_albums} albums`,
|
artist: `${artist_info.total_albums.toLocaleString()} albums`,
|
||||||
image: artist_info.images,
|
image: artist_info.images,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -227,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 () => {
|
||||||
@@ -253,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.getAvailability(track.spotify_id || "")} downloadingCover={cover.downloadingCover} downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")} failedCover={cover.failedCovers.has(track.spotify_id || "")} skippedCover={cover.skippedCovers.has(track.spotify_id || "")} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onOpenFolder={handleOpenFolder}/>);
|
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, undefined, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, undefined, 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);
|
||||||
@@ -271,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.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} 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);
|
||||||
@@ -285,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);
|
||||||
@@ -331,6 +402,13 @@ function App() {
|
|||||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||||
case "debug":
|
case "debug":
|
||||||
return <DebugLoggerPage />;
|
return <DebugLoggerPage />;
|
||||||
|
case "about":
|
||||||
|
return <AboutPage version={CURRENT_VERSION}/>;
|
||||||
|
case "history":
|
||||||
|
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":
|
||||||
@@ -339,133 +417,145 @@ function App() {
|
|||||||
return <FileManagerPage />;
|
return <FileManagerPage />;
|
||||||
default:
|
default:
|
||||||
return (<>
|
return (<>
|
||||||
<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}>
|
|
||||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
<div className="absolute right-4 top-4">
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
<div className="absolute right-4 top-4">
|
||||||
<X className="h-4 w-4"/>
|
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
</Button>
|
<X className="h-4 w-4"/>
|
||||||
</div>
|
</Button>
|
||||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
</div>
|
||||||
<DialogDescription>
|
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||||
Do you want to fetch metadata for this album?
|
<DialogDescription>
|
||||||
</DialogDescription>
|
Do you want to fetch metadata for this album?
|
||||||
{metadata.selectedAlbum && (<div className="py-2">
|
</DialogDescription>
|
||||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
{metadata.selectedAlbum && (<div className="py-2">
|
||||||
</div>)}
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||||
<DialogFooter>
|
</div>)}
|
||||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
<DialogFooter>
|
||||||
Cancel
|
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
</Button>
|
Cancel
|
||||||
<Button onClick={async () => {
|
</Button>
|
||||||
|
<Button onClick={async () => {
|
||||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||||
if (albumUrl) {
|
if (albumUrl) {
|
||||||
setSpotifyUrl(albumUrl);
|
setSpotifyUrl(albumUrl);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<Search className="h-4 w-4"/>
|
<Search className="h-4 w-4"/>
|
||||||
Fetch Album
|
Fetch Album
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
|
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
|
||||||
setSpotifyUrl(url);
|
setSpotifyUrl(url);
|
||||||
const updatedUrl = await metadata.handleFetchMetadata(url);
|
const updatedUrl = await metadata.handleFetchMetadata(url);
|
||||||
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()}
|
||||||
</>);
|
</>);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (<TooltipProvider>
|
return (<TooltipProvider>
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{renderPage()}
|
{renderPage()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
||||||
|
|
||||||
|
|
||||||
|
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
|
||||||
|
|
||||||
|
|
||||||
|
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
|
||||||
|
<ArrowUp className="h-5 w-5"/>
|
||||||
|
</Button>)}
|
||||||
|
|
||||||
|
|
||||||
|
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCancelNavigation}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDiscardChanges}>
|
||||||
|
Discard Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
||||||
|
<DialogContent className="max-w-[360px] [&>button]:hidden p-6 gap-5">
|
||||||
|
<DialogHeader className="space-y-2">
|
||||||
|
<DialogTitle className="text-lg font-bold tracking-tight">
|
||||||
|
FFmpeg Required
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
|
||||||
|
FFmpeg is essential for SpotiFLAC to function properly.
|
||||||
|
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isInstallingFFmpeg && (<div className="space-y-4">
|
||||||
|
{ffmpegInstallStatus === "extracting" ? (<div className="flex flex-col items-center justify-center py-2 animate-in fade-in duration-500">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
|
||||||
|
<span className="text-sm font-bold tracking-tight">Extracting...</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">Finalizing setup</span>
|
||||||
|
</div>) : (<div className="space-y-3">
|
||||||
|
<div className="flex justify-between text-[11px] font-bold">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider">Downloading...</span>
|
||||||
|
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-primary font-mono tabular-nums">
|
||||||
|
{downloadProgress.mb_downloaded.toFixed(1)}MB
|
||||||
|
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(1)}MB/s`}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tracking-tighter text-primary">{ffmpegInstallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full bg-secondary/30 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_rgba(var(--primary),0.3)]" style={{ width: `${ffmpegInstallProgress}%` }}/>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row gap-3 pt-2">
|
||||||
|
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
|
||||||
|
Exit
|
||||||
|
</Button>)}
|
||||||
|
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={handleInstallFFmpeg} disabled={isInstallingFFmpeg}>
|
||||||
|
{isInstallingFFmpeg ? "Installing..." : "Install now"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
|
||||||
|
|
||||||
|
|
||||||
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
|
|
||||||
|
|
||||||
|
|
||||||
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
|
|
||||||
<ArrowUp className="h-5 w-5"/>
|
|
||||||
</Button>)}
|
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
|
||||||
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleCancelNavigation}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDiscardChanges}>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>);
|
</TooltipProvider>);
|
||||||
}
|
}
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
export const langColors: Record<string, string> = {
|
||||||
|
"TypeScript": "#2b7489",
|
||||||
|
"Go": "#375eab",
|
||||||
|
"Python": "#3572A5",
|
||||||
|
"CSS": "#563d7c",
|
||||||
|
"HTML": "#e44b23",
|
||||||
|
"JavaScript": "#f1e05a",
|
||||||
|
"Java": "#b07219",
|
||||||
|
"C": "#555555",
|
||||||
|
"C Sharp": "#178600",
|
||||||
|
"cpp": "#f34b7d",
|
||||||
|
"Ruby": "#701516",
|
||||||
|
"PHP": "#4F5D95",
|
||||||
|
"Swift": "#ffac45",
|
||||||
|
"Kotlin": "#F18E33",
|
||||||
|
"Rust": "#dea584",
|
||||||
|
"Shell": "#89e051"
|
||||||
|
};
|
||||||
@@ -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 |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #2dc261;
|
||||||
|
fill-rule: evenodd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
|
||||||
|
<g>
|
||||||
|
<g id="Layer_1">
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<g id="Page-1" sketch:type="MSPage">
|
||||||
|
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup">
|
||||||
|
<path id="arrow-right-circle" class="cls-1" d="M350.1,268.7l-81.5,81.5c-5.6,5.6-14.7,5.6-20.4,0-5.6-5.6-5.6-14.8,0-20.5l59.4-59.3h-152.5c-8,0-14.4-6.5-14.4-14.4s6.4-14.4,14.4-14.4h152.5l-59.4-59.3c-5.6-5.6-5.6-14.7,0-20.5,5.6-5.6,14.7-5.6,20.4,0l81.5,81.5c3.5,3.5,4.5,8.2,3.7,12.7.8,4.5-.3,9.2-3.7,12.7h0ZM256,25.6c-127.3,0-230.4,103.1-230.4,230.4s103.2,230.4,230.4,230.4,230.4-103.1,230.4-230.4S383.3,25.6,256,25.6h0Z" sketch:type="MSShapeGroup"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #733e0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #fdc700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #1ed760;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
|
||||||
|
<g>
|
||||||
|
<g id="Layer_1">
|
||||||
|
<g>
|
||||||
|
<g id="_1818452274576">
|
||||||
|
<g id="SVGRepo_iconCarrier">
|
||||||
|
<path class="cls-3" 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.8h0ZM381.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-.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"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path class="cls-2" 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.1Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M113.6,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v35h17.5v-35c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.7,4.4-2.2,5.9-1.5,1.5-3.5,2.2-5.9,2.2s-4.4-.8-5.9-2.3c-1.5-1.5-2.3-3.5-2.3-5.8v-39.5h-17.5v39.5c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
|
||||||
|
<path class="cls-1" d="M175.9,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
|
||||||
|
<path class="cls-1" d="M200.4,434c-2,0-3.7-.7-5.2-2.2-1.5-1.4-2.2-3.2-2.2-5.3s.7-3.8,2.2-5.2c1.4-1.4,3.2-2.2,5.2-2.2h19.5c2,0,3.8.7,5.2,2.2s2.2,3.2,2.2,5.2-.7,3.8-2.2,5.3-3.2,2.2-5.2,2.2h-19.5Z"/>
|
||||||
|
<path class="cls-1" d="M250.3,477.2c-1.4,1.4-3.4,2.1-6,2.1s-4.6-.7-6-2.1c-1.4-1.4-2.1-3.4-2.1-6v-88.4c0-2.6.7-4.6,2.1-6,1.4-1.4,3.4-2.1,6-2.1h16c8.4,0,14.5,2,18.4,5.9,3.9,3.9,5.8,9.9,5.8,18v6.4c0,10.7-3.6,17.6-10.7,20.5v.3c3.9,1.2,6.7,3.6,8.4,7.2s2.5,8.5,2.5,14.7v16.1c0,2.5.2,4.5.6,6.1.4,1.5.6,2.8.6,4.1,0,3.6-2.6,5.4-7.7,5.4s-5.9-1-7.5-2.9c-1.6-2-2.4-5.2-2.4-9.8v-19.8c0-4.8-.8-8.1-2.3-10-1.5-1.9-4.2-2.8-7.9-2.8h-5.6v37.2c0,2.6-.7,4.6-2.1,6ZM252.4,419.1h5.9c3.2,0,5.7-.8,7.3-2.5s2.5-4.5,2.5-8.5v-8c0-3.7-.7-6.4-2-8.1s-3.4-2.6-6.3-2.6h-7.5v29.7Z"/>
|
||||||
|
<path class="cls-1" d="M304,478.4c-2.4,0-4.3-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.9v-87.5c0-2.4.8-4.3,2.3-5.9s3.5-2.3,5.9-2.3h29.8c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-21.7v27.4h15.9c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-15.9v31.9h21.7c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-29.8Z"/>
|
||||||
|
<path class="cls-1" d="M371.2,479.9c-7.9,0-13.8-2.2-17.9-6.6-4.1-4.4-6.1-10.6-6.1-18.6s.7-4.3,2-5.7c1.4-1.4,3.3-2.1,5.7-2.1s4.2.7,5.6,2c1.4,1.3,2.1,3.4,2.1,6.2,0,6.8,2.8,10.1,8.5,10.1s8.5-3.5,8.5-10.4-1-8.1-3-11.4c-2-3.3-5.6-7.3-11-12-6.8-5.9-11.5-11.3-14.1-16.1-2.7-4.8-4-10.2-4-16.2s2.1-14.6,6.3-19c4.2-4.5,10.2-6.7,18.1-6.7s13.5,2.2,17.6,6.6c4.1,4.4,6.2,10.1,6.2,17s-2.6,7.8-7.7,7.8-4.4-.7-5.7-2.2-2-3.3-2-5.6-.7-4.9-2.1-6.4c-1.4-1.5-3.4-2.3-6-2.3-5.5,0-8.2,3.3-8.2,9.9s1,7.3,3.1,10.5c2.1,3.2,5.7,7.2,11,11.9,6.8,6,11.4,11.4,14,16.3,2.6,4.9,3.9,10.5,3.9,17s-2.1,15-6.3,19.5c-4.2,4.6-10.3,6.8-18.3,6.8Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#00bc7d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-music-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M9 17v-13h10v8" /><path d="M9 8h10" /><path d="M19 16v6" /><path d="M22 19l-3 3l-3 -3" /></svg>
|
||||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
|
||||||
|
id="Layer_1" width="512px" height="512px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g fill="#1da0f1">
|
||||||
|
<polygon
|
||||||
|
points="12.153992,10.729553 8.088684,5.041199 5.92041,5.041199 10.956299,12.087097 11.59021,12.97345 15.900635,19.009583 18.068909,19.009583 12.785217,11.615906 " />
|
||||||
|
<path
|
||||||
|
d="M21.15979,1H2.84021C1.823853,1,1,1.823853,1,2.84021v18.31958C1,22.176147,1.823853,23,2.84021,23h18.31958 C22.176147,23,23,22.176147,23,21.15979V2.84021C23,1.823853,22.176147,1,21.15979,1z M15.235352,20l-4.362549-6.213013 L5.411438,20H4l6.246887-7.104675L4,4h4.764648l4.130127,5.881958L18.06958,4h1.411377l-5.95697,6.775635L20,20H15.235352z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,438 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import { GetOSInfo } from "../../wailsjs/go/main/App";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks } from "lucide-react";
|
||||||
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
|
import XProIcon from "@/assets/x-pro.webp";
|
||||||
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
|
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||||
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
|
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||||
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { DragDropMedia } from "./DragDropTextarea";
|
||||||
|
interface AboutPageProps {
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
export function AboutPage({ version }: AboutPageProps) {
|
||||||
|
const [os, setOs] = useState("Unknown");
|
||||||
|
const [location, setLocation] = useState("Unknown");
|
||||||
|
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects">("bug_report");
|
||||||
|
const [bugType, setBugType] = useState("Track");
|
||||||
|
const [problem, setProblem] = useState("");
|
||||||
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
|
const [bugContext, setBugContext] = useState("");
|
||||||
|
const [featureDesc, setFeatureDesc] = useState("");
|
||||||
|
const [useCase, setUseCase] = useState("");
|
||||||
|
const [featureContext, setFeatureContext] = useState("");
|
||||||
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOS = async () => {
|
||||||
|
try {
|
||||||
|
const info = await GetOSInfo();
|
||||||
|
setOs(info);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const userAgent = window.navigator.userAgent;
|
||||||
|
if (userAgent.indexOf("Win") !== -1)
|
||||||
|
setOs("Windows");
|
||||||
|
else if (userAgent.indexOf("Mac") !== -1)
|
||||||
|
setOs("macOS");
|
||||||
|
else if (userAgent.indexOf("Linux") !== -1)
|
||||||
|
setOs("Linux");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchOS();
|
||||||
|
const fetchLocation = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://ipapi.co/json/');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const city = data.city || '';
|
||||||
|
const region = data.region || '';
|
||||||
|
const country = data.country_name || '';
|
||||||
|
const parts = [city, region, country].filter(Boolean);
|
||||||
|
setLocation(parts.join(', ') || 'Unknown');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
setLocation(timezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
setLocation(timezone);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchLocation();
|
||||||
|
const fetchRepoStats = async () => {
|
||||||
|
const CACHE_KEY = 'github_repo_stats';
|
||||||
|
const CACHE_DURATION = 1000 * 60 * 60;
|
||||||
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
if (Date.now() - timestamp < CACHE_DURATION) {
|
||||||
|
setRepoStats(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Failed to parse cache:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const repos = [
|
||||||
|
{ name: 'SpotiDownloader', owner: 'afkarxyz' },
|
||||||
|
{ name: 'SpotiFLAC-Next', owner: 'spotiverse' },
|
||||||
|
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' }
|
||||||
|
];
|
||||||
|
const stats: Record<string, any> = {};
|
||||||
|
for (const repo of repos) {
|
||||||
|
try {
|
||||||
|
const [repoRes, releasesRes, langsRes] = await Promise.all([
|
||||||
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
|
||||||
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
|
||||||
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`)
|
||||||
|
]);
|
||||||
|
if (repoRes.status === 403) {
|
||||||
|
if (cached) {
|
||||||
|
const { data } = JSON.parse(cached);
|
||||||
|
setRepoStats(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (repoRes.ok && releasesRes.ok && langsRes.ok) {
|
||||||
|
const repoData = await repoRes.json();
|
||||||
|
const releases = await releasesRes.json();
|
||||||
|
const languages = await langsRes.json();
|
||||||
|
let totalDownloads = 0;
|
||||||
|
let latestDownloads = 0;
|
||||||
|
if (releases.length > 0) {
|
||||||
|
latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
||||||
|
totalDownloads = releases.reduce((sum: number, release: any) => {
|
||||||
|
return sum + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
const topLangs = Object.entries(languages)
|
||||||
|
.sort(([, a]: any, [, b]: any) => b - a)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(([lang]) => lang);
|
||||||
|
stats[repo.name] = {
|
||||||
|
stars: repoData.stargazers_count,
|
||||||
|
forks: repoData.forks_count,
|
||||||
|
createdAt: repoData.created_at,
|
||||||
|
totalDownloads,
|
||||||
|
latestDownloads,
|
||||||
|
languages: topLangs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Failed to fetch stats for ${repo.name}:`, err);
|
||||||
|
if (cached) {
|
||||||
|
const { data } = JSON.parse(cached);
|
||||||
|
setRepoStats(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRepoStats(stats);
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify({ data: stats, timestamp: Date.now() }));
|
||||||
|
};
|
||||||
|
fetchRepoStats();
|
||||||
|
}, []);
|
||||||
|
const faqs = [
|
||||||
|
{
|
||||||
|
q: "Is this software free?",
|
||||||
|
a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can using this software get my Spotify account suspended or banned?",
|
||||||
|
a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Where does the audio come from?",
|
||||||
|
a: "The audio is fetched using third-party APIs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Why does metadata fetching sometimes fail?",
|
||||||
|
a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Why does Windows Defender or antivirus flag or delete the file?",
|
||||||
|
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 formatTimeAgo = (dateString: string): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const updated = new Date(dateString);
|
||||||
|
const diffMs = now.getTime() - updated.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
if (diffDays === 0)
|
||||||
|
return 'today';
|
||||||
|
if (diffDays === 1)
|
||||||
|
return '1d';
|
||||||
|
if (diffDays < 30)
|
||||||
|
return `${diffDays}d`;
|
||||||
|
if (diffMonths === 1)
|
||||||
|
return '1mo';
|
||||||
|
if (diffMonths < 12)
|
||||||
|
return `${diffMonths}mo`;
|
||||||
|
const diffYears = Math.floor(diffMonths / 12);
|
||||||
|
return `${diffYears}y`;
|
||||||
|
};
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 1000) {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
const getLangColor = (lang: string): string => {
|
||||||
|
return langColors[lang] || '#858585';
|
||||||
|
};
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const title = activeTab === "bug_report"
|
||||||
|
? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}`
|
||||||
|
: `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
|
||||||
|
let bodyContent = "";
|
||||||
|
if (activeTab === "bug_report") {
|
||||||
|
const contextContent = bugContext.trim() ? bugContext.trim() : "Type here or send screenshot/recording";
|
||||||
|
bodyContent = `### [Bug Report]
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
${problem || "Type here"}
|
||||||
|
|
||||||
|
#### Type
|
||||||
|
${bugType}
|
||||||
|
|
||||||
|
#### Spotify URL
|
||||||
|
${spotifyUrl || "Type here"}
|
||||||
|
|
||||||
|
#### Additional Context
|
||||||
|
${contextContent}
|
||||||
|
|
||||||
|
#### Environment
|
||||||
|
- SpotiFLAC Version: ${version}
|
||||||
|
- OS: ${os}
|
||||||
|
- Location: ${location}`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const contextContent = featureContext.trim() ? featureContext.trim() : "Type here or send screenshot/recording";
|
||||||
|
bodyContent = `### [Feature Request]
|
||||||
|
|
||||||
|
#### Description
|
||||||
|
${featureDesc || "Type here"}
|
||||||
|
|
||||||
|
#### Use Case
|
||||||
|
${useCase || "Type here"}
|
||||||
|
|
||||||
|
#### Additional Context
|
||||||
|
${contextContent}`;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
title: title,
|
||||||
|
body: bodyContent
|
||||||
|
});
|
||||||
|
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
|
||||||
|
openExternal(url);
|
||||||
|
};
|
||||||
|
return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
|
<Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
|
||||||
|
<Bug className="h-4 w-4"/>
|
||||||
|
Bug Report
|
||||||
|
</Button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
|
||||||
|
{activeTab === "bug_report" && (<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">
|
||||||
|
<Label>Problem</Label>
|
||||||
|
<Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)}/>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<ToggleGroup type="single" value={bugType} onValueChange={(val) => {
|
||||||
|
if (val)
|
||||||
|
setBugType(val);
|
||||||
|
}} className="justify-start w-full cursor-pointer">
|
||||||
|
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">Track</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">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>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Spotify URL</Label>
|
||||||
|
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)}/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 flex-col">
|
||||||
|
<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)}/>
|
||||||
|
</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>
|
||||||
|
<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 === "faq" && (<ScrollArea className="h-full">
|
||||||
|
<div className="p-1 pr-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>
|
||||||
|
</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>)}
|
||||||
|
</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"/>)}
|
||||||
@@ -90,7 +96,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
<span>{albumInfo.release_date}</span>
|
<span>{albumInfo.release_date}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
{albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +107,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{onDownloadAllLyrics && (<Tooltip>
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -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,36 +445,83 @@ 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
|
||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{onDownloadAllLyrics && (<Tooltip>
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
||||||
import { Upload, Download, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { IsFFmpegInstalled, DownloadFFmpeg, ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
|
import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
|
||||||
interface AudioFile {
|
interface AudioFile {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,9 +36,6 @@ const M4A_CODEC_OPTIONS = [
|
|||||||
];
|
];
|
||||||
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
||||||
export function AudioConverterPage() {
|
export function AudioConverterPage() {
|
||||||
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
|
|
||||||
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
|
|
||||||
const downloadProgress = useDownloadProgress();
|
|
||||||
const [files, setFiles] = useState<AudioFile[]>(() => {
|
const [files, setFiles] = useState<AudioFile[]>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
@@ -114,9 +109,6 @@ export function AudioConverterPage() {
|
|||||||
console.error("Failed to save state:", err);
|
console.error("Failed to save state:", err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
|
||||||
checkFfmpegInstallation();
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveState({ files, outputFormat, bitrate, m4aCodec });
|
saveState({ files, outputFormat, bitrate, m4aCodec });
|
||||||
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
||||||
@@ -147,41 +139,6 @@ export function AudioConverterPage() {
|
|||||||
window.removeEventListener("focus", checkFullscreen);
|
window.removeEventListener("focus", checkFullscreen);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const checkFfmpegInstallation = async () => {
|
|
||||||
try {
|
|
||||||
const installed = await IsFFmpegInstalled();
|
|
||||||
setFfmpegInstalled(installed);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error("Failed to check ffmpeg:", err);
|
|
||||||
setFfmpegInstalled(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleInstallFfmpeg = async () => {
|
|
||||||
setInstallingFfmpeg(true);
|
|
||||||
try {
|
|
||||||
const result = await DownloadFFmpeg();
|
|
||||||
if (result.success) {
|
|
||||||
toast.success("FFmpeg Installed", {
|
|
||||||
description: "FFmpeg has been installed successfully",
|
|
||||||
});
|
|
||||||
setFfmpegInstalled(true);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
toast.error("Installation Failed", {
|
|
||||||
description: result.error || "Failed to install FFmpeg",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
toast.error("Installation Failed", {
|
|
||||||
description: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
setInstallingFfmpeg(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleSelectFiles = async () => {
|
const handleSelectFiles = async () => {
|
||||||
try {
|
try {
|
||||||
const selectedFiles = await SelectAudioFiles();
|
const selectedFiles = await SelectAudioFiles();
|
||||||
@@ -250,15 +207,13 @@ export function AudioConverterPage() {
|
|||||||
addFiles(paths);
|
addFiles(paths);
|
||||||
}, [addFiles]);
|
}, [addFiles]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ffmpegInstalled === true) {
|
OnFileDrop((x, y, paths) => {
|
||||||
OnFileDrop((x, y, paths) => {
|
handleFileDrop(x, y, paths);
|
||||||
handleFileDrop(x, y, paths);
|
}, true);
|
||||||
}, true);
|
return () => {
|
||||||
return () => {
|
OnFileDropOff();
|
||||||
OnFileDropOff();
|
};
|
||||||
};
|
}, [handleFileDrop]);
|
||||||
}
|
|
||||||
}, [handleFileDrop, ffmpegInstalled]);
|
|
||||||
const removeFile = (path: string) => {
|
const removeFile = (path: string) => {
|
||||||
setFiles((prev) => prev.filter((f) => f.path !== path));
|
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||||
};
|
};
|
||||||
@@ -336,62 +291,24 @@ export function AudioConverterPage() {
|
|||||||
};
|
};
|
||||||
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
||||||
const successCount = files.filter((f) => f.status === "success").length;
|
const successCount = files.filter((f) => f.status === "success").length;
|
||||||
if (ffmpegInstalled === false) {
|
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} border-muted-foreground/30`}>
|
<div className="flex items-center justify-between">
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||||
<Download className="h-8 w-8 text-primary"/>
|
{files.length > 0 && (<div className="flex gap-2">
|
||||||
</div>
|
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
<Upload className="h-4 w-4"/>
|
||||||
FFmpeg is required to convert audio files
|
Add More
|
||||||
</p>
|
</Button>
|
||||||
<Button onClick={handleInstallFfmpeg} disabled={installingFfmpeg} size="lg">
|
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
||||||
{installingFfmpeg ? (<>
|
<Trash2 className="h-4 w-4"/>
|
||||||
<Spinner className="h-5 w-5"/>
|
Clear All
|
||||||
Installing FFmpeg...
|
</Button>
|
||||||
</>) : (<>
|
|
||||||
<Download className="h-5 w-5"/>
|
|
||||||
Install FFmpeg
|
|
||||||
</>)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{installingFfmpeg && downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<div className="w-full max-w-md mt-6 space-y-2 px-4">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Downloading FFmpeg</span>
|
|
||||||
<span className="font-mono tabular-nums">
|
|
||||||
{downloadProgress.mb_downloaded.toFixed(2)} MB
|
|
||||||
{downloadProgress.speed_mbps > 0 && (<span className="text-muted-foreground ml-2">
|
|
||||||
@ {downloadProgress.speed_mbps.toFixed(2)} MB/s
|
|
||||||
</span>)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={Math.min(100, (downloadProgress.mb_downloaded / 200) * 100)} className="h-2"/>
|
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
|
||||||
{files.length > 0 && (<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
|
||||||
<Upload className="h-4 w-4"/>
|
|
||||||
Add More
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
|
||||||
<Trash2 className="h-4 w-4"/>
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
|
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/10"
|
||||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -403,110 +320,110 @@ export function AudioConverterPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||||
{files.length === 0 ? (<>
|
{files.length === 0 ? (<>
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
<Upload className="h-8 w-8 text-primary"/>
|
<Upload className="h-8 w-8 text-primary"/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
{isDragging
|
{isDragging
|
||||||
? "Drop your audio files here"
|
? "Drop your audio files here"
|
||||||
: "Drag and drop audio files here, or click the button below to select"}
|
: "Drag and drop audio files here, or click the button below to select"}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleSelectFiles} size="lg">
|
<Button onClick={handleSelectFiles} size="lg">
|
||||||
<Upload className="h-5 w-5"/>
|
<Upload className="h-5 w-5"/>
|
||||||
Select Files
|
Select Files
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
Supported formats: FLAC, MP3
|
Supported formats: FLAC, MP3
|
||||||
</p>
|
</p>
|
||||||
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||||
|
|
||||||
<div className="space-y-2 pb-4 border-b shrink-0">
|
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="whitespace-nowrap">Format:</Label>
|
<Label className="whitespace-nowrap">Format:</Label>
|
||||||
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
||||||
if (value && !isFormatDisabled)
|
if (value && !isFormatDisabled)
|
||||||
setOutputFormat(value as "mp3" | "m4a");
|
setOutputFormat(value as "mp3" | "m4a");
|
||||||
}} disabled={isFormatDisabled}>
|
}} disabled={isFormatDisabled}>
|
||||||
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||||
MP3
|
MP3
|
||||||
</ToggleGroupItem>)}
|
</ToggleGroupItem>)}
|
||||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||||
M4A
|
M4A
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
|
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
|
||||||
<Label className="whitespace-nowrap">Codec:</Label>
|
<Label className="whitespace-nowrap">Codec:</Label>
|
||||||
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
|
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
|
||||||
if (value)
|
if (value)
|
||||||
setM4aCodec(value as "aac" | "alac");
|
setM4aCodec(value as "aac" | "alac");
|
||||||
}}>
|
}}>
|
||||||
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</ToggleGroupItem>))}
|
</ToggleGroupItem>))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
|
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
|
||||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||||
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
||||||
if (value)
|
if (value)
|
||||||
setBitrate(value);
|
setBitrate(value);
|
||||||
}}>
|
}}>
|
||||||
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</ToggleGroupItem>))}
|
</ToggleGroupItem>))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{files.length} file(s) • {successCount} converted
|
{files.length} file(s) • {successCount} converted
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
|
||||||
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
|
|
||||||
{getStatusIcon(file.status)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
|
||||||
{file.error && (<p className="truncate text-xs text-destructive">
|
|
||||||
{file.error}
|
|
||||||
</p>)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs uppercase text-muted-foreground">
|
|
||||||
{file.format}
|
|
||||||
</span>
|
|
||||||
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
|
|
||||||
<X className="h-4 w-4"/>
|
|
||||||
</Button>)}
|
|
||||||
</div>))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||||
<div className="flex justify-center pt-4 border-t shrink-0">
|
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
|
{getStatusIcon(file.status)}
|
||||||
{converting ? (<>
|
<div className="flex-1 min-w-0">
|
||||||
<Spinner className="h-4 w-4"/>
|
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||||
Converting...
|
{file.error && (<p className="truncate text-xs text-destructive">
|
||||||
</>) : (<>
|
{file.error}
|
||||||
<WandSparkles className="h-4 w-4"/>
|
</p>)}
|
||||||
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
</div>
|
||||||
</>)}
|
<span className="text-xs text-muted-foreground">
|
||||||
</Button>
|
{formatFileSize(file.size)}
|
||||||
</div>
|
</span>
|
||||||
</div>)}
|
<span className="text-xs uppercase text-muted-foreground">
|
||||||
</div>
|
{file.format}
|
||||||
|
</span>
|
||||||
|
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>)}
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||||
|
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
|
||||||
|
{converting ? (<>
|
||||||
|
<Spinner className="h-4 w-4"/>
|
||||||
|
Converting...
|
||||||
|
</>) : (<>
|
||||||
|
<WandSparkles className="h-4 w-4"/>
|
||||||
|
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
|
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { backend } from "../../wailsjs/go/models";
|
import { backend } from "../../wailsjs/go/models";
|
||||||
interface DownloadQueueProps {
|
interface DownloadQueueProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -47,6 +48,17 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
console.error("Failed to clear history:", error);
|
console.error("Failed to clear history:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
await ClearAllDownloads();
|
||||||
|
const info = await GetDownloadQueue();
|
||||||
|
setQueueInfo(info);
|
||||||
|
toast.success("Download queue reset");
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to reset queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "downloading":
|
case "downloading":
|
||||||
@@ -72,8 +84,8 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
queued: "outline",
|
queued: "outline",
|
||||||
};
|
};
|
||||||
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||||
{status}
|
{status}
|
||||||
</Badge>);
|
</Badge>);
|
||||||
};
|
};
|
||||||
const formatDuration = (startTimestamp: number) => {
|
const formatDuration = (startTimestamp: number) => {
|
||||||
if (startTimestamp === 0)
|
if (startTimestamp === 0)
|
||||||
@@ -94,138 +106,138 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (<Dialog open={isOpen} onOpenChange={onClose}>
|
return (<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
||||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
<DialogTitle className="text-lg font-semibold hover:text-primary transition-colors cursor-pointer" onClick={handleReset}>Download Queue</DialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
|
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
|
||||||
<Trash2 className="h-3 w-3"/>
|
<Trash2 className="h-3 w-3"/>
|
||||||
Clear History
|
Clear History
|
||||||
</Button>)}
|
</Button>)}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||||
<X className="h-4 w-4"/>
|
<X className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
<span className="text-muted-foreground">Queued:</span>
|
<span className="text-muted-foreground">Queued:</span>
|
||||||
<span className="font-semibold">{queueInfo.queued_count}</span>
|
<span className="font-semibold">{queueInfo.queued_count}</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
|
||||||
<span className="text-muted-foreground">Completed:</span>
|
|
||||||
<span className="font-semibold">{queueInfo.completed_count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
|
||||||
<span className="text-muted-foreground">Skipped:</span>
|
|
||||||
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
|
||||||
<span className="text-muted-foreground">Failed:</span>
|
|
||||||
<span className="font-semibold">{queueInfo.failed_count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
||||||
|
<span className="text-muted-foreground">Completed:</span>
|
||||||
|
<span className="font-semibold">{queueInfo.completed_count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
||||||
|
<span className="text-muted-foreground">Skipped:</span>
|
||||||
|
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
||||||
|
<span className="text-muted-foreground">Failed:</span>
|
||||||
|
<span className="font-semibold">{queueInfo.failed_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
<span className="text-muted-foreground">Downloaded:</span>
|
<span className="text-muted-foreground">Downloaded:</span>
|
||||||
<span className="font-semibold font-mono">
|
<span className="font-semibold font-mono">
|
||||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
<span className="text-muted-foreground">Speed:</span>
|
<span className="text-muted-foreground">Speed:</span>
|
||||||
<span className="font-semibold font-mono">
|
<span className="font-semibold font-mono">
|
||||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
|
|
||||||
<span className="text-muted-foreground">Duration:</span>
|
|
||||||
<span className="font-semibold font-mono">
|
|
||||||
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
|
<span className="font-semibold font-mono">
|
||||||
|
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
|
||||||
<div className="space-y-2 py-4">
|
|
||||||
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
|
||||||
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
|
||||||
<p>No downloads in queue</p>
|
|
||||||
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
<div className="space-y-2 py-4">
|
||||||
<div className="flex-1 min-w-0">
|
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||||
<p className="font-medium truncate">{item.track_name}</p>
|
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p>No downloads in queue</p>
|
||||||
{item.artist_name}
|
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
|
||||||
{item.album_name && ` • ${item.album_name}`}
|
<div className="flex items-start gap-3">
|
||||||
</p>
|
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||||
</div>
|
|
||||||
{getStatusBadge(item.status)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
<span>
|
<div className="flex-1 min-w-0">
|
||||||
{item.progress > 0
|
<p className="font-medium truncate">{item.track_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{item.artist_name}
|
||||||
|
{item.album_name && ` • ${item.album_name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(item.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||||
|
<span>
|
||||||
|
{item.progress > 0
|
||||||
? `${item.progress.toFixed(2)} MB`
|
? `${item.progress.toFixed(2)} MB`
|
||||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||||
? "Downloading..."
|
? "Downloading..."
|
||||||
: "Starting..."}
|
: "Starting..."}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{item.speed > 0
|
{item.speed > 0
|
||||||
? `${item.speed.toFixed(2)} MB/s`
|
? `${item.speed.toFixed(2)} MB/s`
|
||||||
: queueInfo.current_speed > 0
|
: queueInfo.current_speed > 0
|
||||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||||
: "—"}
|
: "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
|
||||||
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||||
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
||||||
File already exists
|
</div>)}
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
|
||||||
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
|
||||||
{item.error_message}
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
||||||
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
File already exists
|
||||||
{item.file_path}
|
</div>)}
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
||||||
</div>)))}
|
{item.error_message}
|
||||||
</div>
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
|
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{item.file_path}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)))}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>);
|
</DialogContent>
|
||||||
|
</Dialog>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -17,12 +17,6 @@ const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (windo
|
|||||||
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
|
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
|
||||||
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
|
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
|
||||||
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
|
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
|
||||||
const IsFFprobeInstalled = (): Promise<boolean> => (window as any)['go']['main']['App']['IsFFprobeInstalled']();
|
|
||||||
const DownloadFFmpeg = (): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
error?: string;
|
|
||||||
}> => (window as any)['go']['main']['App']['DownloadFFmpeg']();
|
|
||||||
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
|
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
|
||||||
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
|
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
|
||||||
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
|
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
|
||||||
@@ -118,8 +112,6 @@ export function FileManagerPage() {
|
|||||||
const [metadataFile, setMetadataFile] = useState<string>("");
|
const [metadataFile, setMetadataFile] = useState<string>("");
|
||||||
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
|
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
|
||||||
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
||||||
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
|
|
||||||
const [installingFFprobe, setInstallingFFprobe] = useState(false);
|
|
||||||
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
|
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
|
||||||
const [lyricsContent, setLyricsContent] = useState("");
|
const [lyricsContent, setLyricsContent] = useState("");
|
||||||
const [lyricsFile, setLyricsFile] = useState("");
|
const [lyricsFile, setLyricsFile] = useState("");
|
||||||
@@ -279,14 +271,6 @@ export function FileManagerPage() {
|
|||||||
toast.error("No files selected");
|
toast.error("No files selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
|
|
||||||
if (hasM4A) {
|
|
||||||
const installed = await IsFFprobeInstalled();
|
|
||||||
if (!installed) {
|
|
||||||
setShowFFprobeDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
||||||
setPreviewData(result);
|
setPreviewData(result);
|
||||||
@@ -299,13 +283,6 @@ export function FileManagerPage() {
|
|||||||
};
|
};
|
||||||
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
|
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (filePath.toLowerCase().endsWith(".m4a")) {
|
|
||||||
const installed = await IsFFprobeInstalled();
|
|
||||||
if (!installed) {
|
|
||||||
setShowFFprobeDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMetadataFile(filePath);
|
setMetadataFile(filePath);
|
||||||
setLoadingMetadata(true);
|
setLoadingMetadata(true);
|
||||||
try {
|
try {
|
||||||
@@ -321,24 +298,6 @@ export function FileManagerPage() {
|
|||||||
setLoadingMetadata(false);
|
setLoadingMetadata(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleInstallFFprobe = async () => {
|
|
||||||
setInstallingFFprobe(true);
|
|
||||||
try {
|
|
||||||
const result = await DownloadFFmpeg();
|
|
||||||
if (result.success) {
|
|
||||||
toast.success("FFprobe installed successfully");
|
|
||||||
setShowFFprobeDialog(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
toast.error("Failed to install FFprobe", { description: result.error || result.message });
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
toast.error("Failed to install FFprobe", { description: err instanceof Error ? err.message : "Unknown error" });
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
setInstallingFFprobe(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
|
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setLyricsFile(filePath);
|
setLyricsFile(filePath);
|
||||||
@@ -389,17 +348,17 @@ export function FileManagerPage() {
|
|||||||
if (!text)
|
if (!text)
|
||||||
return null;
|
return null;
|
||||||
return (<div key={index} className="flex items-center gap-2 py-1">
|
return (<div key={index} className="flex items-center gap-2 py-1">
|
||||||
<Badge variant="secondary" className="font-mono text-xs shrink-0">
|
<Badge variant="secondary" className="font-mono text-xs shrink-0">
|
||||||
{formatTimestamp(timestamp)}
|
{formatTimestamp(timestamp)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-sm">{text}</span>
|
<span className="text-sm">{text}</span>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
if (!line.trim())
|
if (!line.trim())
|
||||||
return null;
|
return null;
|
||||||
return (<div key={index} className="py-1">
|
return (<div key={index} className="py-1">
|
||||||
<span className="text-sm">{line}</span>
|
<span className="text-sm">{line}</span>
|
||||||
</div>);
|
</div>);
|
||||||
}).filter(item => item !== null);
|
}).filter(item => item !== null);
|
||||||
};
|
};
|
||||||
const handleCopyLyrics = async () => {
|
const handleCopyLyrics = async () => {
|
||||||
@@ -463,331 +422,318 @@ export function FileManagerPage() {
|
|||||||
};
|
};
|
||||||
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
|
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
|
||||||
return nodes.map((node) => (<div key={node.path}>
|
return nodes.map((node) => (<div key={node.path}>
|
||||||
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
|
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
|
||||||
{node.is_dir ? (<>
|
{node.is_dir ? (<>
|
||||||
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
|
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
|
||||||
if (el)
|
if (el)
|
||||||
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
|
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
|
||||||
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
|
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
|
||||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||||
</>) : (<>
|
</>) : (<>
|
||||||
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
|
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
|
||||||
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
|
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
|
||||||
</>)}
|
</>)}
|
||||||
<span className="truncate text-sm flex-1">
|
<span className="truncate text-sm flex-1">
|
||||||
{node.name}
|
{node.name}
|
||||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||||
</span>
|
</span>
|
||||||
{!node.is_dir && (<>
|
{!node.is_dir && (<>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
|
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground"/>
|
<Info className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>View Metadata</TooltipContent>
|
<TooltipContent>View Metadata</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
|
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
|
||||||
</div>));
|
</div>));
|
||||||
};
|
};
|
||||||
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
|
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
|
||||||
return nodes.map((node) => (<div key={node.path}>
|
return nodes.map((node) => (<div key={node.path}>
|
||||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
|
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
|
||||||
{node.is_dir ? (<>
|
{node.is_dir ? (<>
|
||||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||||
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
|
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
|
||||||
<span className="truncate text-sm flex-1">
|
<span className="truncate text-sm flex-1">
|
||||||
{node.name}
|
{node.name}
|
||||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||||
</span>
|
</span>
|
||||||
{!node.is_dir && (<>
|
{!node.is_dir && (<>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
||||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Rename</TooltipContent>
|
<TooltipContent>Rename</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
|
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
|
||||||
</div>));
|
</div>));
|
||||||
};
|
};
|
||||||
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
|
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
|
||||||
return nodes.map((node) => (<div key={node.path}>
|
return nodes.map((node) => (<div key={node.path}>
|
||||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
|
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
|
||||||
{node.is_dir ? (<>
|
{node.is_dir ? (<>
|
||||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||||
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
|
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
|
||||||
<span className="truncate text-sm flex-1">
|
<span className="truncate text-sm flex-1">
|
||||||
{node.name}
|
{node.name}
|
||||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||||
</span>
|
</span>
|
||||||
{!node.is_dir && (<>
|
{!node.is_dir && (<>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
||||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Rename</TooltipContent>
|
<TooltipContent>Rename</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
|
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
|
||||||
</div>));
|
</div>));
|
||||||
};
|
};
|
||||||
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
|
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
|
||||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h1 className="text-2xl font-bold">File Manager</h1>
|
<h1 className="text-2xl font-bold">File Manager</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
|
||||||
|
<Button onClick={handleSelectFolder}>
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
|
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
|
||||||
|
<FileMusic className="h-4 w-4"/>
|
||||||
|
Track ({allAudioFiles.length})
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
|
||||||
|
<FileText className="h-4 w-4"/>
|
||||||
|
Lyric ({allLyricFiles.length})
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
|
||||||
|
<Image className="h-4 w-4"/>
|
||||||
|
Cover ({allCoverFiles.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{activeTab === "track" && (<div className="space-y-2 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-sm">Rename Format</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={formatPreset} onValueChange={setFormatPreset}>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
|
<SelectContent>
|
||||||
<Button onClick={handleSelectFolder}>
|
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||||
<FolderOpen className="h-4 w-4"/>
|
</SelectContent>
|
||||||
Browse
|
</Select>
|
||||||
</Button>
|
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
|
||||||
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
|
<Tooltip>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}/>
|
<TooltipTrigger asChild>
|
||||||
Refresh
|
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
|
||||||
</Button>
|
<RotateCcw className="h-4 w-4"/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Reset to Default</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||||
|
</p>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex gap-2 border-b shrink-0">
|
|
||||||
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
|
|
||||||
<FileMusic className="h-4 w-4"/>
|
|
||||||
Track ({allAudioFiles.length})
|
|
||||||
</Button>
|
|
||||||
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
|
|
||||||
<FileText className="h-4 w-4"/>
|
|
||||||
Lyric ({allLyricFiles.length})
|
|
||||||
</Button>
|
|
||||||
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
|
|
||||||
<Image className="h-4 w-4"/>
|
|
||||||
Cover ({allCoverFiles.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
|
||||||
{activeTab === "track" && (<div className="space-y-2 shrink-0">
|
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<Label className="text-sm">Rename Format</Label>
|
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
||||||
<Tooltip>
|
{allSelected ? "Deselect All" : "Select All"}
|
||||||
<TooltipTrigger asChild>
|
</Button>
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<TooltipContent side="right">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
|
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
|
||||||
</TooltipContent>
|
<Eye className="h-4 w-4"/>
|
||||||
</Tooltip>
|
Preview
|
||||||
</div>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
|
||||||
<Select value={formatPreset} onValueChange={setFormatPreset}>
|
<Pencil className="h-4 w-4"/>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
Rename
|
||||||
<SelectContent>
|
</Button>
|
||||||
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
</div>
|
||||||
</SelectContent>
|
</div>)}
|
||||||
</Select>
|
|
||||||
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
|
|
||||||
<RotateCcw className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Reset to Default</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
|
||||||
</p>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
|
||||||
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
|
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
|
||||||
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
|
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
|
||||||
<div className="flex items-center gap-4">
|
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
|
||||||
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
|
||||||
{allSelected ? "Deselect All" : "Select All"}
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
|
|
||||||
<Eye className="h-4 w-4"/>
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
|
|
||||||
<Pencil className="h-4 w-4"/>
|
|
||||||
Rename
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
|
|
||||||
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
|
|
||||||
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
|
|
||||||
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
|
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
|
||||||
renderCoverTree(filteredFiles))}
|
renderCoverTree(filteredFiles))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Reset to Default?</DialogTitle>
|
|
||||||
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
|
||||||
<Button onClick={resetToDefault}>Reset</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle>Reset to Default?</DialogTitle>
|
||||||
<DialogTitle>Rename Preview</DialogTitle>
|
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
|
||||||
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
<DialogFooter>
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 py-4">
|
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||||
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
|
<Button onClick={resetToDefault}>Reset</Button>
|
||||||
<div className="text-sm">
|
</DialogFooter>
|
||||||
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
</DialogContent>
|
||||||
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1">→ {item.new_name}</div>}
|
</Dialog>
|
||||||
</div>
|
|
||||||
</div>))}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
|
|
||||||
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
|
|
||||||
<Button onClick={handleRename} disabled={renaming}>
|
|
||||||
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
|
|
||||||
</Button>
|
|
||||||
</>)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
|
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>File Metadata</DialogTitle>
|
|
||||||
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
|
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
|
|
||||||
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
|
|
||||||
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||||
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle>Rename Preview</DialogTitle>
|
||||||
<DialogTitle>FFprobe Required</DialogTitle>
|
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
|
||||||
<DialogDescription>Reading M4A metadata requires FFprobe. Would you like to download and install it now?</DialogDescription>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
<div className="flex-1 overflow-y-auto space-y-2 py-4">
|
||||||
<DialogFooter>
|
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
|
||||||
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>Cancel</Button>
|
<div className="text-sm">
|
||||||
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
|
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
||||||
{installingFFprobe ? <><Spinner className="h-4 w-4"/>Installing...</> : "Install FFprobe"}
|
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1">→ {item.new_name}</div>}
|
||||||
|
</div>
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
|
||||||
|
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleRename} disabled={renaming}>
|
||||||
|
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</>)}
|
||||||
</DialogContent>
|
</DialogFooter>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
|
|
||||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Lyrics Preview</DialogTitle>
|
|
||||||
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex gap-2 border-b pb-2">
|
|
||||||
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
|
|
||||||
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto py-4">
|
|
||||||
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
|
|
||||||
{renderSyncedLyrics(lyricsContent)}
|
|
||||||
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
|
|
||||||
{getPlainLyrics(lyricsContent) || "No lyrics content"}
|
|
||||||
</pre>)}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
|
|
||||||
{copySuccess ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
|
||||||
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
<DialogContent className="max-w-lg [&>button]:hidden">
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle>File Metadata</DialogTitle>
|
||||||
<DialogTitle>Cover Preview</DialogTitle>
|
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||||
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
|
||||||
<div className="flex items-center justify-center p-4">
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
|
||||||
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain"/> : <div className="text-muted-foreground">Loading...</div>}
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
|
||||||
</div>
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
|
||||||
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
|
||||||
</DialogContent>
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
|
||||||
</Dialog>
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
|
||||||
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
|
||||||
|
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
|
||||||
|
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
|
|
||||||
<DialogContent className="max-w-2xl [&>button]:hidden">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Rename File</DialogTitle>
|
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
|
||||||
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
|
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||||
</DialogHeader>
|
<DialogHeader>
|
||||||
<div className="py-4">
|
<DialogTitle>Lyrics Preview</DialogTitle>
|
||||||
<Label htmlFor="newName" className="text-sm">New Name</Label>
|
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
</DialogHeader>
|
||||||
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
|
<div className="flex gap-2 border-b pb-2">
|
||||||
|
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
|
||||||
|
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
|
||||||
|
{renderSyncedLyrics(lyricsContent)}
|
||||||
|
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
|
||||||
|
{getPlainLyrics(lyricsContent) || "No lyrics content"}
|
||||||
|
</pre>)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
|
||||||
|
{copySuccess ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
|
||||||
|
<DialogContent className="max-w-lg [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Cover Preview</DialogTitle>
|
||||||
|
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain"/> : <div className="text-muted-foreground">Loading...</div>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
|
||||||
|
<DialogContent className="max-w-2xl [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename File</DialogTitle>
|
||||||
|
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Label htmlFor="newName" className="text-sm">New Name</Label>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !manualRenaming)
|
if (e.key === "Enter" && !manualRenaming)
|
||||||
handleConfirmManualRename();
|
handleConfirmManualRename();
|
||||||
}}/>
|
}}/>
|
||||||
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
|
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
</div>
|
||||||
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
|
<DialogFooter>
|
||||||
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
|
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
|
||||||
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
|
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
|
||||||
</Button>
|
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
|
||||||
</DialogFooter>
|
</Button>
|
||||||
</DialogContent>
|
</DialogFooter>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
</div>);
|
</Dialog>
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
|
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
interface DownloadHistoryItem {
|
||||||
|
id: string;
|
||||||
|
spotify_id: string;
|
||||||
|
title: string;
|
||||||
|
artists: string;
|
||||||
|
album: string;
|
||||||
|
duration_str: string;
|
||||||
|
cover_url: string;
|
||||||
|
quality: string;
|
||||||
|
format: string;
|
||||||
|
path: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
interface FetchHistoryItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
info: string;
|
||||||
|
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 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 fetchDownloadHistory = async () => {
|
||||||
|
try {
|
||||||
|
const items = await GetDownloadHistory();
|
||||||
|
setDownloadHistory(items || []);
|
||||||
|
}
|
||||||
|
catch (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(() => {
|
||||||
|
if (activeTab === "downloads") {
|
||||||
|
fetchDownloadHistory();
|
||||||
|
const interval = setInterval(fetchDownloadHistory, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fetchFetchHistory();
|
||||||
|
const interval = setInterval(fetchFetchHistory, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
let result = [...downloadHistory];
|
||||||
|
if (downloadSearchQuery) {
|
||||||
|
const query = downloadSearchQuery.toLowerCase();
|
||||||
|
result = result.filter(item => item.title.toLowerCase().includes(query) ||
|
||||||
|
item.artists.toLowerCase().includes(query) ||
|
||||||
|
item.album.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
const parseDuration = (str: string) => {
|
||||||
|
const parts = str.split(':').map(Number);
|
||||||
|
if (parts.length === 2)
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
if (parts.length === 3)
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
result.sort((a, b) => {
|
||||||
|
switch (downloadSortBy) {
|
||||||
|
case "default":
|
||||||
|
case "date_desc": return b.timestamp - a.timestamp;
|
||||||
|
case "date_asc": return a.timestamp - b.timestamp;
|
||||||
|
case "title_asc": return a.title.localeCompare(b.title);
|
||||||
|
case "title_desc": return b.title.localeCompare(a.title);
|
||||||
|
case "artist_asc": 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFilteredDownloadHistory(result);
|
||||||
|
setDownloadCurrentPage(1);
|
||||||
|
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
|
||||||
|
useEffect(() => {
|
||||||
|
let result = [...fetchHistory];
|
||||||
|
if (activeFetchTab !== "all") {
|
||||||
|
result = result.filter(item => item.type.toLowerCase() === activeFetchTab.toLowerCase());
|
||||||
|
}
|
||||||
|
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')[] = [];
|
||||||
|
pages.push(1);
|
||||||
|
if (current <= 7) {
|
||||||
|
for (let i = 2; i <= 10; i++)
|
||||||
|
pages.push(i);
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(total);
|
||||||
|
}
|
||||||
|
else if (current >= total - 7) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
for (let i = total - 9; i <= total; i++)
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(current - 1);
|
||||||
|
pages.push(current);
|
||||||
|
pages.push(current + 1);
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(total);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
const renderDownloadHistory = () => {
|
||||||
|
const totalPages = Math.ceil(filteredDownloadHistory.length / ITEMS_PER_PAGE);
|
||||||
|
const startIndex = (downloadCurrentPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
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">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">History</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<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"}`}>
|
||||||
|
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>
|
||||||
|
|
||||||
|
{activeTab === "downloads" && (<div className="mt-6">
|
||||||
|
{renderDownloadHistory()}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "fetches" && (<div className="mt-6">
|
||||||
|
{renderFetchHistory()}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<Dialog open={showClearDownloadConfirm} onOpenChange={setShowClearDownloadConfirm}>
|
||||||
|
<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={() => setShowClearDownloadConfirm(false)} className="cursor-pointer">Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleClearDownloadHistory} className="cursor-pointer">
|
||||||
|
Clear History
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={showClearFetchConfirm} onOpenChange={setShowClearFetchConfirm}>
|
||||||
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clear {activeFetchTab.charAt(0).toUpperCase() + activeFetchTab.slice(1)} History?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will remove all {activeFetchTab} entries from your fetch history cache.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowClearFetchConfirm(false)} className="cursor-pointer">Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleClearFetchHistory} 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"/>)}
|
||||||
@@ -97,10 +103,10 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</div>
|
</div>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
{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">
|
||||||
@@ -110,7 +116,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{onDownloadAllLyrics && (<Tooltip>
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -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>
|
||||||
<div className="flex items-center bg-muted rounded-md p-1">
|
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
|
||||||
<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 ? <Link className="h-4 w-4"/> : <Search className="h-4 w-4"/>}
|
||||||
? "bg-background text-foreground shadow-sm"
|
</Button>
|
||||||
: "text-muted-foreground hover:text-foreground")}>
|
</TooltipTrigger>
|
||||||
<Link className="h-3.5 w-3.5"/>
|
<TooltipContent>
|
||||||
URL
|
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
|
||||||
</button>
|
</TooltipContent>
|
||||||
<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
|
</Tooltip>
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "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">
|
||||||
<div className="relative flex-1">
|
{!searchMode ? (<>
|
||||||
{!searchMode ? (<>
|
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
|
||||||
<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("")}>
|
||||||
{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"/>
|
||||||
<XCircle className="h-4 w-4"/>
|
</button>)}
|
||||||
</button>)}
|
</>) : (<>
|
||||||
</>) : (<>
|
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
||||||
<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={() => {
|
||||||
{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">
|
||||||
<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)}>
|
||||||
{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"/>)}
|
||||||
{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-1 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<p className="font-medium truncate">{track.name}</p>
|
<p className="font-medium truncate">{track.name}</p>
|
||||||
<p className="text-sm text-muted-foreground truncate">{track.artists}</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">
|
||||||
</div>
|
E
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
</span>)}
|
||||||
{formatDuration(track.duration_ms || 0)}
|
</div>
|
||||||
</span>
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
</button>))}
|
{track.artists}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0">
|
||||||
|
{formatDuration(track.duration_ms || 0)}
|
||||||
|
</span>
|
||||||
|
</button>))}
|
||||||
|
|
||||||
|
{activeTab === "albums" &&
|
||||||
{activeTab === "albums" && searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
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"/>)}
|
{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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{album.name}</p>
|
<p className="font-medium truncate">{album.name}</p>
|
||||||
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
</div>
|
{album.artists}
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
</p>
|
||||||
{album.release_date || ""}
|
</div>
|
||||||
</span>
|
<span className="text-sm text-muted-foreground shrink-0">
|
||||||
</button>))}
|
{album.release_date || ""}
|
||||||
|
</span>
|
||||||
|
</button>))}
|
||||||
|
|
||||||
|
{activeTab === "artists" &&
|
||||||
{activeTab === "artists" && searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
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"/>)}
|
{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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{artist.name}</p>
|
<p className="font-medium truncate">{artist.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">Artist</p>
|
<p className="text-sm text-muted-foreground">Artist</p>
|
||||||
</div>
|
</div>
|
||||||
</button>))}
|
</button>))}
|
||||||
|
|
||||||
|
{activeTab === "playlists" &&
|
||||||
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
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"/>)}
|
{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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{playlist.name}</p>
|
<p className="font-medium truncate">{playlist.name}</p>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{playlist.owner || ""}
|
{playlist.owner || ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>))}
|
</button>))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||||
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||||
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
{isLoadingMore ? (<>
|
||||||
{isLoadingMore ? (<>
|
<Spinner />
|
||||||
<Spinner />
|
Loading...
|
||||||
Loading...
|
</>) : (<>
|
||||||
</>) : (<>
|
<ChevronDown className="h-4 w-4"/>
|
||||||
<ChevronDown className="h-4 w-4"/>
|
Load More
|
||||||
Load More
|
</>)}
|
||||||
</>)}
|
</Button>
|
||||||
</Button>
|
</div>)}
|
||||||
|
</>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
</>)}
|
</div>);
|
||||||
</div>)}
|
|
||||||
</div>);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,25 +5,31 @@ import { InputWithContext } from "@/components/ui/input-with-context";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info, ArrowRight } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
const TidalIcon = ({ className }: {
|
||||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
className?: string;
|
||||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||||
</svg>);
|
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||||
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
</svg>);
|
||||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
const QobuzIcon = ({ className }: {
|
||||||
</svg>);
|
className?: string;
|
||||||
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||||
</svg>);
|
</svg>);
|
||||||
|
const AmazonIcon = ({ className }: {
|
||||||
|
className?: string;
|
||||||
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||||
|
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||||
|
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||||
|
</svg>);
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||||
onResetRequest?: (resetFn: () => void) => void;
|
onResetRequest?: (resetFn: () => void) => void;
|
||||||
@@ -76,13 +82,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
setSavedSettings(settingsWithDefaults);
|
setSavedSettings(settingsWithDefaults);
|
||||||
setTempSettings(settingsWithDefaults);
|
setTempSettings(settingsWithDefaults);
|
||||||
saveSettings(settingsWithDefaults);
|
await saveSettings(settingsWithDefaults);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadDefaults();
|
loadDefaults();
|
||||||
}, []);
|
}, []);
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
saveSettings(tempSettings);
|
await saveSettings(tempSettings);
|
||||||
setSavedSettings(tempSettings);
|
setSavedSettings(tempSettings);
|
||||||
toast.success("Settings saved");
|
toast.success("Settings saved");
|
||||||
onUnsavedChangesChange?.(false);
|
onUnsavedChangesChange?.(false);
|
||||||
@@ -109,166 +115,235 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
toast.error(`Error selecting folder: ${error}`);
|
toast.error(`Error selecting folder: ${error}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (<div className="space-y-6">
|
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||||
|
};
|
||||||
|
const handleQobuzQualityChange = (value: "6" | "7") => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||||
|
};
|
||||||
|
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||||
|
};
|
||||||
|
return (<div className="space-y-4">
|
||||||
|
<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"/>
|
||||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||||
<FolderOpen className="h-4 w-4"/>
|
<FolderOpen className="h-4 w-4"/>
|
||||||
Browse
|
Browse
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</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">
|
||||||
<SelectValue placeholder="Select theme mode"/>
|
<SelectValue placeholder="Select theme mode"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="theme">Accent</Label>
|
||||||
|
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
||||||
|
<SelectTrigger id="theme">
|
||||||
|
<SelectValue placeholder="Select a theme"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full border border-border" style={{
|
||||||
|
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
||||||
|
}}/>
|
||||||
|
{theme.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="font">Font</Label>
|
||||||
|
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||||
|
<SelectTrigger id="font">
|
||||||
|
<SelectValue placeholder="Select a font"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||||
|
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
||||||
|
</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||||
|
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
||||||
|
<SelectTrigger id="downloader" className="h-9 w-fit">
|
||||||
|
<SelectValue placeholder="Select a source"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
<SelectItem value="light">Light</SelectItem>
|
<SelectItem value="tidal">
|
||||||
<SelectItem value="dark">Dark</SelectItem>
|
<span className="flex items-center"><TidalIcon />Tidal</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz">
|
||||||
|
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon">
|
||||||
|
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{tempSettings.downloader === "auto" && (<>
|
||||||
<div className="space-y-2">
|
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({ ...prev, autoOrder: value }))}>
|
||||||
<Label htmlFor="theme">Accent</Label>
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
<SelectValue />
|
||||||
<SelectTrigger id="theme">
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="Select a theme"/>
|
<SelectContent>
|
||||||
|
<SelectItem value="tidal-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="tidal-amazon">
|
||||||
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-tidal">
|
||||||
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-amazon">
|
||||||
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-tidal">
|
||||||
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="tidal-qobuz-amazon">
|
||||||
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="tidal-amazon-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-tidal-amazon">
|
||||||
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-amazon-tidal">
|
||||||
|
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-tidal-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-qobuz-tidal">
|
||||||
|
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={tempSettings.autoQuality || "16"} onValueChange={handleAutoQualityChange}>
|
||||||
|
<SelectTrigger className="h-9 w-fit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
|
||||||
|
<SelectItem value="24">24-bit/48kHz</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||||
|
<SelectTrigger className="h-9 w-fit">
|
||||||
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||||
<span className="flex items-center gap-2">
|
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
|
||||||
<span className="w-3 h-3 rounded-full border border-border" style={{
|
|
||||||
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
|
||||||
}}/>
|
|
||||||
{theme.label}
|
|
||||||
</span>
|
|
||||||
</SelectItem>))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>)}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||||
<div className="space-y-2">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<Label htmlFor="font">Font</Label>
|
<SelectValue />
|
||||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
|
||||||
<SelectTrigger id="font">
|
|
||||||
<SelectValue placeholder="Select a font"/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
|
||||||
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
<SelectItem value="7">24-bit/48kHz</SelectItem>
|
||||||
</SelectItem>))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>)}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
||||||
<div className="flex items-center gap-3">
|
16-bit/44.1kHz / 24-bit/48kHz
|
||||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
</div>)}
|
||||||
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
{((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
|
(tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "7") ||
|
||||||
<div className="space-y-2">
|
(tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pl-1">
|
||||||
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex gap-2">
|
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/>
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
<Label htmlFor="allow-fallback" className="text-sm font-normal">Allow Quality Fallback (16-bit)</Label>
|
||||||
<SelectTrigger id="downloader" className="h-9 w-fit">
|
</div>
|
||||||
<SelectValue placeholder="Select a source"/>
|
</div>)}
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
<div className="flex items-center gap-6">
|
||||||
<SelectItem value="tidal">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center"><TidalIcon />Tidal</span>
|
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
||||||
</SelectItem>
|
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
|
||||||
<SelectItem value="qobuz">
|
|
||||||
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon">
|
|
||||||
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
|
|
||||||
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>)}
|
|
||||||
|
|
||||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
|
|
||||||
<SelectItem value="7">FLAC 24-bit</SelectItem>
|
|
||||||
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>)}
|
|
||||||
|
|
||||||
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="HI_RES">Hi-Res (24-bit/96kHz+)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
|
||||||
<div className="flex items-center gap-6">
|
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
|
||||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
|
|
||||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
|
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
|
||||||
const preset = FOLDER_PRESETS[value];
|
const preset = FOLDER_PRESETS[value];
|
||||||
setTempSettings(prev => ({
|
setTempSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -276,37 +351,37 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
|
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
|
||||||
}));
|
}));
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
|
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
|
||||||
</div>
|
|
||||||
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
|
|
||||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
|
|
||||||
</p>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
|
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
||||||
const preset = FILENAME_PRESETS[value];
|
const preset = FILENAME_PRESETS[value];
|
||||||
setTempSettings(prev => ({
|
setTempSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -314,48 +389,50 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
|||||||
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
|
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
|
||||||
}));
|
}));
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||||
</div>
|
|
||||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
|
||||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
|
||||||
</p>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
|
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||||
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-between pt-4 border-t">
|
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
|
||||||
<RotateCcw className="h-4 w-4"/>
|
|
||||||
Reset to Default
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} className="gap-1.5">
|
|
||||||
<Save className="h-4 w-4"/>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-between pt-3 border-t">
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
<RotateCcw className="h-4 w-4"/>
|
||||||
<DialogHeader>
|
Reset to Default
|
||||||
<DialogTitle>Reset to Default?</DialogTitle>
|
</Button>
|
||||||
<DialogDescription>
|
<Button onClick={handleSave} className="gap-1.5">
|
||||||
This will reset all settings to their default values. Your custom configurations will be lost.
|
<Save className="h-4 w-4"/>
|
||||||
</DialogDescription>
|
Save Changes
|
||||||
</DialogHeader>
|
</Button>
|
||||||
<DialogFooter>
|
</div>
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
|
||||||
<Button onClick={handleReset}>Reset</Button>
|
|
||||||
</DialogFooter>
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
</DialogContent>
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
</Dialog>
|
<DialogHeader>
|
||||||
</div>);
|
<DialogTitle>Reset to Default?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will reset all settings to their default values. Your custom configurations will be lost.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleReset}>Reset</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +1,124 @@
|
|||||||
import { HomeIcon } from "@/components/ui/home";
|
import { HomeIcon } from "@/components/ui/home";
|
||||||
|
import { HistoryIcon } from "@/components/ui/history-icon";
|
||||||
import { SettingsIcon } from "@/components/ui/settings";
|
import { SettingsIcon } from "@/components/ui/settings";
|
||||||
import { ActivityIcon } from "@/components/ui/activity";
|
import { ActivityIcon } from "@/components/ui/activity";
|
||||||
import { TerminalIcon } from "@/components/ui/terminal";
|
import { TerminalIcon } from "@/components/ui/terminal";
|
||||||
import { FileMusicIcon } from "@/components/ui/file-music";
|
import { FileMusicIcon } from "@/components/ui/file-music";
|
||||||
import { FilePenIcon } from "@/components/ui/file-pen";
|
import { FilePenIcon } from "@/components/ui/file-pen";
|
||||||
import { GithubIcon } from "@/components/ui/github";
|
|
||||||
import { BlocksIcon } from "@/components/ui/blocks";
|
|
||||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
|
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
onPageChange: (page: PageType) => void;
|
onPageChange: (page: PageType) => void;
|
||||||
}
|
}
|
||||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||||
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
|
||||||
<HomeIcon size={20}/>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>Home</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
<Tooltip delayDuration={0}>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
||||||
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
<HomeIcon size={20}/>
|
||||||
<SettingsIcon size={20}/>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right">
|
||||||
<TooltipContent side="right">
|
<p>Home</p>
|
||||||
<p>Settings</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
<Tooltip delayDuration={0}>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
|
||||||
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
|
<HistoryIcon size={20}/>
|
||||||
<ActivityIcon size={20}/>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right">
|
||||||
<TooltipContent side="right">
|
<p>History</p>
|
||||||
<p>Audio Quality Analyzer</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
<Tooltip delayDuration={0}>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
|
||||||
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
|
<ActivityIcon size={20}/>
|
||||||
<FileMusicIcon size={20}/>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right">
|
||||||
<TooltipContent side="right">
|
<p>Audio Quality Analyzer</p>
|
||||||
<p>Audio Converter</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
<Tooltip delayDuration={0}>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
|
||||||
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
|
<FileMusicIcon size={20}/>
|
||||||
<FilePenIcon size={20}/>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right">
|
||||||
<TooltipContent side="right">
|
<p>Audio Converter</p>
|
||||||
<p>File Manager</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
<Tooltip delayDuration={0}>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
|
||||||
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
<FilePenIcon size={20}/>
|
||||||
<TerminalIcon size={20}/>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="right">
|
||||||
<TooltipContent side="right">
|
<p>File Manager</p>
|
||||||
<p>Debug Logs</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
||||||
<div className="mt-auto flex flex-col gap-2">
|
<TerminalIcon size={20} loop={true}/>
|
||||||
<Tooltip delayDuration={0}>
|
</Button>
|
||||||
<TooltipTrigger asChild>
|
</TooltipTrigger>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?labels=bug&body=%23%23%23%20Problem%0AExplain%20the%20issue%20briefly.%0A%0A%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%20Spotify%20URL%0APaste%20the%20link%20here.%0A%0A%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS")}>
|
<TooltipContent side="right">
|
||||||
<GithubIcon size={20}/>
|
<p>Debug Logs</p>
|
||||||
</Button>
|
</TooltipContent>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>Report Bug</p>
|
<Tooltip delayDuration={0}>
|
||||||
</TooltipContent>
|
<TooltipTrigger asChild>
|
||||||
</Tooltip>
|
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
||||||
<Tooltip delayDuration={0}>
|
<SettingsIcon size={20}/>
|
||||||
<TooltipTrigger asChild>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://exyezed.cc/")}>
|
</TooltipTrigger>
|
||||||
<BlocksIcon size={20}/>
|
<TooltipContent side="right">
|
||||||
</Button>
|
<p>Settings</p>
|
||||||
</TooltipTrigger>
|
</TooltipContent>
|
||||||
<TooltipContent side="right">
|
</Tooltip>
|
||||||
<p>Other Projects</p>
|
</div>
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip delayDuration={0}>
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<TooltipTrigger asChild>
|
<Tooltip delayDuration={0}>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
<TooltipTrigger asChild>
|
||||||
<CoffeeIcon size={20}/>
|
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
||||||
</Button>
|
<BadgeAlertIcon size={20}/>
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent side="right">
|
</TooltipTrigger>
|
||||||
<p>Every coffee helps me keep going</p>
|
<TooltipContent side="right">
|
||||||
</TooltipContent>
|
<p>About</p>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
</div>
|
</Tooltip>
|
||||||
</div>);
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Every coffee helps me keep going</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } 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 type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
interface TrackInfoProps {
|
interface TrackInfoProps {
|
||||||
track: TrackMetadata & {
|
track: TrackMetadata & {
|
||||||
album_name: string;
|
album_name: string;
|
||||||
@@ -30,8 +31,10 @@ 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 formatDuration = (ms: number) => {
|
const formatDuration = (ms: number) => {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds = Math.floor((ms % 60000) / 1000);
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
@@ -43,97 +46,113 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
return plays;
|
return plays;
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
};
|
};
|
||||||
return (<Card>
|
return (<Card className="relative">
|
||||||
<CardContent className="px-6">
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
<div className="flex gap-6 items-start">
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
<div className="shrink-0">
|
<XCircle className="h-5 w-5"/>
|
||||||
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
</Button>
|
||||||
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
</div>)}
|
||||||
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
<CardContent className="px-6">
|
||||||
{formatDuration(track.duration_ms)}
|
<div className="flex gap-6 items-start">
|
||||||
</div>
|
<div className="shrink-0">
|
||||||
</div>)}
|
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
||||||
</div>
|
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
||||||
<div className="flex-1 space-y-4 min-w-0">
|
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
||||||
<div className="space-y-1">
|
{formatDuration(track.duration_ms)}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
|
||||||
{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>
|
|
||||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
</div>)}
|
||||||
<div className="space-y-1">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Album</p>
|
|
||||||
<p className="font-medium truncate">{track.album_name}</p>
|
|
||||||
</div>
|
|
||||||
{track.plays && (<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Total Plays</p>
|
|
||||||
<p className="font-medium">{formatPlays(track.plays)}</p>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
|
||||||
<p className="font-medium">{track.release_date}</p>
|
|
||||||
</div>
|
|
||||||
{track.copyright && (<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Copyright</p>
|
|
||||||
<p className="font-medium truncate" title={track.copyright}>
|
|
||||||
{track.copyright}
|
|
||||||
</p>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
|
||||||
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
|
||||||
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
|
||||||
<Download className="h-4 w-4"/>
|
|
||||||
Download
|
|
||||||
</>)}
|
|
||||||
</Button>
|
|
||||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
|
||||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Download Lyric</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>)}
|
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingCover}>
|
|
||||||
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Download Cover</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>)}
|
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
|
|
||||||
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{availability ? (<div className="flex items-center gap-2">
|
|
||||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
</div>) : (<p>Check Availability</p>)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>)}
|
|
||||||
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
|
||||||
<FolderOpen className="h-4 w-4"/>
|
|
||||||
Open Folder
|
|
||||||
</Button>)}
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
</Card>);
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Album</p>
|
||||||
|
<p className="font-medium truncate">{track.album_name}</p>
|
||||||
|
</div>
|
||||||
|
{track.plays && (<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Plays</p>
|
||||||
|
<p className="font-medium">{formatPlays(track.plays)}</p>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||||
|
<p className="font-medium">{track.release_date}</p>
|
||||||
|
</div>
|
||||||
|
{track.copyright && (<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Copyright</p>
|
||||||
|
<p className="font-medium truncate" title={track.copyright}>
|
||||||
|
{track.copyright}
|
||||||
|
</p>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||||
|
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
||||||
|
<Download className="h-4 w-4"/>
|
||||||
|
Download
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
|
{track.spotify_id && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => playPreview(track.spotify_id!, track.name)} variant="outline" size="icon" disabled={loadingPreview === track.spotify_id}>
|
||||||
|
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||||
|
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Lyric</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingCover}>
|
||||||
|
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Cover</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" size="icon" disabled={checkingAvailability}>
|
||||||
|
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{availability ? (<div className="flex items-center gap-2">
|
||||||
|
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
</div>) : (<p>Check Availability</p>)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
|
Open Folder
|
||||||
|
</Button>)}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } 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 { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
interface TrackListProps {
|
interface TrackListProps {
|
||||||
tracks: TrackMetadata[];
|
tracks: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -52,6 +53,7 @@ interface TrackListProps {
|
|||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
|
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
|
||||||
|
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||||
let filteredTracks = tracks.filter((track) => {
|
let filteredTracks = tracks.filter((track) => {
|
||||||
if (!searchQuery)
|
if (!searchQuery)
|
||||||
return true;
|
return true;
|
||||||
@@ -118,6 +120,35 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const endIndex = startIndex + itemsPerPage;
|
const endIndex = startIndex + itemsPerPage;
|
||||||
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
||||||
|
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
|
||||||
|
if (total <= 10) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
|
pages.push(1);
|
||||||
|
if (current <= 7) {
|
||||||
|
for (let i = 2; i <= 10; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(total);
|
||||||
|
}
|
||||||
|
else if (current >= total - 7) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
for (let i = total - 9; i <= total; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(current - 1);
|
||||||
|
pages.push(current);
|
||||||
|
pages.push(current + 1);
|
||||||
|
pages.push('ellipsis');
|
||||||
|
pages.push(total);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
||||||
const allSelected = tracksWithIsrc.length > 0 &&
|
const allSelected = tracksWithIsrc.length > 0 &&
|
||||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||||
@@ -135,192 +166,206 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
};
|
};
|
||||||
return (<div className="space-y-4">
|
return (<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||||
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||||
</th>)}
|
</th>)}
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||||
Title
|
Title
|
||||||
</th>
|
</th>
|
||||||
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||||
Album
|
Album
|
||||||
</th>)}
|
</th>)}
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||||
Duration
|
Duration
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
||||||
Plays
|
Plays
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||||
{showCheckboxes && (<td className="p-4 align-middle">
|
{showCheckboxes && (<td className="p-4 align-middle">
|
||||||
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
||||||
</td>)}
|
</td>)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span>{startIndex + index + 1}</span>
|
<span>{startIndex + index + 1}</span>
|
||||||
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
||||||
? "text-green-500"
|
? "text-green-500"
|
||||||
: track.status === "DOWN"
|
: track.status === "DOWN"
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: track.status === "NEW"
|
: track.status === "NEW"
|
||||||
? "text-blue-500"
|
? "text-blue-500"
|
||||||
: ""}`}>
|
: ""}`}>
|
||||||
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
||||||
</span>)}
|
</span>)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 align-middle">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
||||||
|
{track.name}
|
||||||
|
</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}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<span className="text-sm text-muted-foreground">
|
||||||
<td className="p-4 align-middle">
|
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
|
||||||
{track.name}
|
|
||||||
</span>) : (<span className="font-medium">{track.name}</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}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
|
||||||
const artistNames = track.artists.split(", ").map(name => name.trim());
|
const artistNames = track.artists.split(", ").map(name => name.trim());
|
||||||
return artistNames.map((name, i) => {
|
return artistNames.map((name, i) => {
|
||||||
const artistData = track.artists_data![i];
|
const artistData = track.artists_data![i];
|
||||||
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
||||||
return (<span key={artistData?.id || i}>
|
return (<span key={artistData?.id || i}>
|
||||||
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
id: artistData.id,
|
id: artistData.id,
|
||||||
name: name,
|
name: name,
|
||||||
external_urls: artistData.external_urls,
|
external_urls: artistData.external_urls,
|
||||||
})}>
|
})}>
|
||||||
{name}
|
{name}
|
||||||
</span>) : (name)}
|
</span>) : (name)}
|
||||||
{i < artistNames.length - 1 && ", "}
|
{i < artistNames.length - 1 && ", "}
|
||||||
</span>);
|
</span>);
|
||||||
});
|
});
|
||||||
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
id: track.artist_id!,
|
id: track.artist_id!,
|
||||||
name: track.artists,
|
name: track.artists,
|
||||||
external_urls: track.artist_url!,
|
external_urls: track.artist_url!,
|
||||||
})}>
|
})}>
|
||||||
{track.artists}
|
{track.artists}
|
||||||
</span>) : (track.artists)}
|
</span>) : (track.artists)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
||||||
id: track.album_id!,
|
id: track.album_id!,
|
||||||
name: track.album_name,
|
name: track.album_name,
|
||||||
external_urls: track.album_url!,
|
external_urls: track.album_url!,
|
||||||
})}>
|
})}>
|
||||||
{track.album_name}
|
{track.album_name}
|
||||||
</span>) : (track.album_name)}
|
</span>) : (track.album_name)}
|
||||||
</td>)}
|
</td>)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||||
{formatDuration(track.duration_ms)}
|
{formatDuration(track.duration_ms)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
||||||
{track.plays ? formatPlays(track.plays) : ""}
|
{track.plays ? formatPlays(track.plays) : ""}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle text-center">
|
<td className="p-4 align-middle text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{track.isrc && (<Tooltip>
|
{track.isrc && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="sm" disabled={isDownloading || downloadingTrack === track.isrc}>
|
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||||
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
{track.spotify_id && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="sm" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
<Button onClick={() => playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" disabled={loadingPreview === track.spotify_id}>
|
||||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Lyric</p>
|
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => {
|
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="icon" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||||
|
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Lyric</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => {
|
||||||
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||||
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
||||||
}} size="sm" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
|
}} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
|
||||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Cover</p>
|
<p>Download Cover</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
||||||
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
||||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||||
</div>) : (<p>Check Availability</p>)}
|
</div>) : (<p>Check Availability</p>)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (<Pagination>
|
{totalPages > 1 && (<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious href="#" onClick={(e) => {
|
<PaginationPrevious href="#" onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentPage > 1)
|
if (currentPage > 1)
|
||||||
onPageChange(currentPage - 1);
|
onPageChange(currentPage - 1);
|
||||||
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
|
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||||
<PaginationLink href="#" onClick={(e) => {
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>) : (<PaginationItem key={page}>
|
||||||
|
<PaginationLink href="#" onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onPageChange(page);
|
onPageChange(page);
|
||||||
}} isActive={currentPage === page} className="cursor-pointer">
|
}} isActive={currentPage === page} className="cursor-pointer">
|
||||||
{page}
|
{page}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>))}
|
</PaginationItem>)))}
|
||||||
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext href="#" onClick={(e) => {
|
<PaginationNext href="#" onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (currentPage < totalPages)
|
if (currentPage < totalPages)
|
||||||
onPageChange(currentPage + 1);
|
onPageChange(currentPage + 1);
|
||||||
}} className={currentPage === totalPages
|
}} className={currentPage === totalPages
|
||||||
? "pointer-events-none opacity-50"
|
? "pointer-events-none opacity-50"
|
||||||
: "cursor-pointer"}/>
|
: "cursor-pointer"}/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>)}
|
</Pagination>)}
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
import type { Variants } from "motion/react";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface BadgeAlertIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const ICON_VARIANTS: Variants = {
|
||||||
|
normal: { scale: 1, rotate: 0 },
|
||||||
|
animate: {
|
||||||
|
scale: [1, 1.1, 1.1, 1.1, 1],
|
||||||
|
rotate: [0, -3, 3, -2, 2, 0],
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
times: [0, 0.2, 0.4, 0.6, 1],
|
||||||
|
ease: "easeInOut",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start("animate"),
|
||||||
|
stopAnimation: () => controls.start("normal"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("animate");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("normal");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
|
||||||
|
<line x1="12" x2="12" y1="8" y2="12"/>
|
||||||
|
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
||||||
|
</motion.svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
BadgeAlertIcon.displayName = "BadgeAlertIcon";
|
||||||
|
export { BadgeAlertIcon };
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import type { Variants } from 'motion/react';
|
|
||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
|
||||||
import { motion, useAnimation } from 'motion/react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
export interface BlocksIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
const VARIANTS: Variants = {
|
|
||||||
normal: { translateX: 0, translateY: 0 },
|
|
||||||
animate: { translateX: -4, translateY: 4 },
|
|
||||||
};
|
|
||||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const controls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: () => controls.start('animate'),
|
|
||||||
stopAnimation: () => controls.start('normal'),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!isControlledRef.current) {
|
|
||||||
controls.start('animate');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
}, [controls, onMouseEnter]);
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!isControlledRef.current) {
|
|
||||||
controls.start('normal');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
}, [controls, onMouseLeave]);
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
|
||||||
<motion.path d="M14 3h7v7h-7z" variants={VARIANTS} animate={controls}/>
|
|
||||||
</svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
BlocksIcon.displayName = 'BlocksIcon';
|
|
||||||
export { BlocksIcon };
|
|
||||||
@@ -16,9 +16,9 @@ const buttonVariants = cva("inline-flex items-center justify-center gap-2 whites
|
|||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "h-9 w-9 p-0",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "h-8 w-8 p-0",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "h-10 w-10 p-0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface CoffeeIconHandle {
|
|||||||
}
|
}
|
||||||
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
size?: number;
|
size?: number;
|
||||||
|
loop?: boolean;
|
||||||
}
|
}
|
||||||
const PATH_VARIANTS: Variants = {
|
const PATH_VARIANTS: Variants = {
|
||||||
normal: {
|
normal: {
|
||||||
@@ -27,7 +28,7 @@ const PATH_VARIANTS: Variants = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
|
||||||
const controls = useAnimation();
|
const controls = useAnimation();
|
||||||
const isControlledRef = useRef(false);
|
const isControlledRef = useRef(false);
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
@@ -55,12 +56,12 @@ const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter
|
|||||||
}, [controls, onMouseLeave]);
|
}, [controls, onMouseLeave]);
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
|
||||||
<motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/>
|
<motion.path d="M10 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.2}/>
|
||||||
<motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/>
|
<motion.path d="M14 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.4}/>
|
||||||
<motion.path d="M6 2v2" animate={controls} variants={PATH_VARIANTS} custom={0}/>
|
<motion.path d="M6 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0}/>
|
||||||
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
|
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>);
|
</div>);
|
||||||
});
|
});
|
||||||
CoffeeIcon.displayName = 'CoffeeIcon';
|
CoffeeIcon.displayName = 'CoffeeIcon';
|
||||||
export { CoffeeIcon };
|
export { CoffeeIcon };
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
import { motion, useAnimation } from 'motion/react';
|
import { motion, useAnimation } from 'motion/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface FileMusicIconHandle {
|
export interface FileMusicIconHandle {
|
||||||
startAnimation: () => void;
|
startAnimation: () => void;
|
||||||
stopAnimation: () => void;
|
stopAnimation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
|
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATH_VARIANTS: Variants = {
|
const PATH_VARIANTS: Variants = {
|
||||||
normal: {
|
normal: {
|
||||||
pathLength: 1,
|
pathLength: 1,
|
||||||
@@ -28,91 +25,40 @@ const PATH_VARIANTS: Variants = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(
|
const controls = useAnimation();
|
||||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
const isControlledRef = useRef(false);
|
||||||
const controls = useAnimation();
|
useImperativeHandle(ref, () => {
|
||||||
const isControlledRef = useRef(false);
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
useImperativeHandle(ref, () => {
|
startAnimation: () => controls.start('animate'),
|
||||||
isControlledRef.current = true;
|
stopAnimation: () => controls.start('normal'),
|
||||||
return {
|
};
|
||||||
startAnimation: () => controls.start('animate'),
|
});
|
||||||
stopAnimation: () => controls.start('normal'),
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
};
|
if (!isControlledRef.current) {
|
||||||
});
|
controls.start('animate');
|
||||||
|
}
|
||||||
const handleMouseEnter = useCallback(
|
else {
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
onMouseEnter?.(e);
|
||||||
if (!isControlledRef.current) {
|
}
|
||||||
controls.start('animate');
|
}, [controls, onMouseEnter]);
|
||||||
} else {
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
onMouseEnter?.(e);
|
if (!isControlledRef.current) {
|
||||||
}
|
controls.start('normal');
|
||||||
},
|
}
|
||||||
[controls, onMouseEnter]
|
else {
|
||||||
);
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
const handleMouseLeave = useCallback(
|
}, [controls, onMouseLeave]);
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
if (!isControlledRef.current) {
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
controls.start('normal');
|
<motion.path d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
} else {
|
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
onMouseLeave?.(e);
|
<motion.path d="M8 20v-7l3 1.474" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
}
|
<motion.circle cx="6" cy="20" r="2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
},
|
|
||||||
[controls, onMouseLeave]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(className)}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<motion.path
|
|
||||||
d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M14 2v5a1 1 0 0 0 1 1h5"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M8 20v-7l3 1.474"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx="6"
|
|
||||||
cy="20"
|
|
||||||
r="2"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
FileMusicIcon.displayName = 'FileMusicIcon';
|
FileMusicIcon.displayName = 'FileMusicIcon';
|
||||||
export { FileMusicIcon };
|
export { FileMusicIcon };
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
import { motion, useAnimation } from 'motion/react';
|
import { motion, useAnimation } from 'motion/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface FilePenIconHandle {
|
export interface FilePenIconHandle {
|
||||||
startAnimation: () => void;
|
startAnimation: () => void;
|
||||||
stopAnimation: () => void;
|
stopAnimation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
|
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATH_VARIANTS: Variants = {
|
const PATH_VARIANTS: Variants = {
|
||||||
normal: {
|
normal: {
|
||||||
pathLength: 1,
|
pathLength: 1,
|
||||||
@@ -28,83 +25,39 @@ const PATH_VARIANTS: Variants = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(
|
const controls = useAnimation();
|
||||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
const isControlledRef = useRef(false);
|
||||||
const controls = useAnimation();
|
useImperativeHandle(ref, () => {
|
||||||
const isControlledRef = useRef(false);
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
useImperativeHandle(ref, () => {
|
startAnimation: () => controls.start('animate'),
|
||||||
isControlledRef.current = true;
|
stopAnimation: () => controls.start('normal'),
|
||||||
return {
|
};
|
||||||
startAnimation: () => controls.start('animate'),
|
});
|
||||||
stopAnimation: () => controls.start('normal'),
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
};
|
if (!isControlledRef.current) {
|
||||||
});
|
controls.start('animate');
|
||||||
|
}
|
||||||
const handleMouseEnter = useCallback(
|
else {
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
onMouseEnter?.(e);
|
||||||
if (!isControlledRef.current) {
|
}
|
||||||
controls.start('animate');
|
}, [controls, onMouseEnter]);
|
||||||
} else {
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
onMouseEnter?.(e);
|
if (!isControlledRef.current) {
|
||||||
}
|
controls.start('normal');
|
||||||
},
|
}
|
||||||
[controls, onMouseEnter]
|
else {
|
||||||
);
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
const handleMouseLeave = useCallback(
|
}, [controls, onMouseLeave]);
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
if (!isControlledRef.current) {
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
controls.start('normal');
|
<motion.path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
} else {
|
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
onMouseLeave?.(e);
|
<motion.path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
}
|
|
||||||
},
|
|
||||||
[controls, onMouseLeave]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(className)}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<motion.path
|
|
||||||
d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M14 2v5a1 1 0 0 0 1 1h5"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"
|
|
||||||
variants={PATH_VARIANTS}
|
|
||||||
animate={controls}
|
|
||||||
initial="normal"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
FilePenIcon.displayName = 'FilePenIcon';
|
FilePenIcon.displayName = 'FilePenIcon';
|
||||||
export { FilePenIcon };
|
export { FilePenIcon };
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import type { Variants } from 'motion/react';
|
|
||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
|
||||||
import { motion, useAnimation } from 'motion/react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
export interface GithubIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
const BODY_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
opacity: 1,
|
|
||||||
pathLength: 1,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: [0, 1],
|
|
||||||
pathLength: [0, 1],
|
|
||||||
scale: [0.9, 1],
|
|
||||||
transition: {
|
|
||||||
duration: 0.4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const TAIL_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
draw: {
|
|
||||||
pathLength: [0, 1],
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wag: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
|
||||||
transition: {
|
|
||||||
duration: 2.5,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
repeat: Infinity,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const bodyControls = useAnimation();
|
|
||||||
const tailControls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: async () => {
|
|
||||||
bodyControls.start('animate');
|
|
||||||
await tailControls.start('draw');
|
|
||||||
tailControls.start('wag');
|
|
||||||
},
|
|
||||||
stopAnimation: () => {
|
|
||||||
bodyControls.start('normal');
|
|
||||||
tailControls.start('normal');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!isControlledRef.current) {
|
|
||||||
bodyControls.start('animate');
|
|
||||||
await tailControls.start('draw');
|
|
||||||
tailControls.start('wag');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
}, [bodyControls, onMouseEnter, tailControls]);
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!isControlledRef.current) {
|
|
||||||
bodyControls.start('normal');
|
|
||||||
tailControls.start('normal');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
}, [bodyControls, tailControls, onMouseLeave]);
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<motion.path variants={BODY_VARIANTS} initial="normal" animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
|
|
||||||
<motion.path variants={TAIL_VARIANTS} initial="normal" animate={tailControls} d="M9 18c-4.51 2-5-2-7-2"/>
|
|
||||||
</svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
GithubIcon.displayName = 'GithubIcon';
|
|
||||||
export { GithubIcon };
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
import type { Transition, Variants } from "motion/react";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface HistoryIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface HistoryIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const ARROW_TRANSITION: Transition = {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 250,
|
||||||
|
damping: 25,
|
||||||
|
};
|
||||||
|
const ARROW_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
rotate: "0deg",
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
rotate: "-50deg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const HAND_TRANSITION: Transition = {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
};
|
||||||
|
const HAND_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
rotate: 0,
|
||||||
|
originX: "0%",
|
||||||
|
originY: "100%",
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
rotate: -360,
|
||||||
|
originX: "0%",
|
||||||
|
originY: "100%",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const MINUTE_HAND_TRANSITION: Transition = {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "easeInOut",
|
||||||
|
};
|
||||||
|
const MINUTE_HAND_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
rotate: 0,
|
||||||
|
originX: "0%",
|
||||||
|
originY: "0%",
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
rotate: -45,
|
||||||
|
originX: "0%",
|
||||||
|
originY: "0%",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const HistoryIcon = forwardRef<HistoryIconHandle, HistoryIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start("animate"),
|
||||||
|
stopAnimation: () => controls.start("normal"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("animate");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("normal");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.g animate={controls} transition={ARROW_TRANSITION} variants={ARROW_VARIANTS}>
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||||
|
<path d="M3 3v5h5"/>
|
||||||
|
</motion.g>
|
||||||
|
<motion.line animate={controls} initial="normal" transition={HAND_TRANSITION} variants={HAND_VARIANTS} x1="12" x2="12" y1="12" y2="7"/>
|
||||||
|
<motion.line animate={controls} initial="normal" transition={MINUTE_HAND_TRANSITION} variants={MINUTE_HAND_VARIANTS} x1="12" x2="16" y1="12" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
HistoryIcon.displayName = "HistoryIcon";
|
||||||
|
export { HistoryIcon };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (<TabsPrimitive.List data-slot="tabs-list" className={cn("bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (<TabsPrimitive.Trigger data-slot="tabs-trigger" className={cn("inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm flex-1", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (<TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -10,6 +10,7 @@ export interface TerminalIconHandle {
|
|||||||
}
|
}
|
||||||
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
|
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
size?: number;
|
size?: number;
|
||||||
|
loop?: boolean;
|
||||||
}
|
}
|
||||||
const LINE_VARIANTS: Variants = {
|
const LINE_VARIANTS: Variants = {
|
||||||
normal: { opacity: 1 },
|
normal: { opacity: 1 },
|
||||||
@@ -22,7 +23,7 @@ const LINE_VARIANTS: Variants = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
|
||||||
const controls = useAnimation();
|
const controls = useAnimation();
|
||||||
const isControlledRef = useRef(false);
|
const isControlledRef = useRef(false);
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
@@ -50,10 +51,10 @@ const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMous
|
|||||||
}, [controls, onMouseLeave]);
|
}, [controls, onMouseLeave]);
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<polyline points="4 17 10 11 4 5"/>
|
<polyline points="4 17 10 11 4 5"/>
|
||||||
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={controls} initial="normal"/>
|
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={loop ? 'animate' : controls} initial="normal"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>);
|
</div>);
|
||||||
});
|
});
|
||||||
TerminalIcon.displayName = 'TerminalIcon';
|
TerminalIcon.displayName = 'TerminalIcon';
|
||||||
export { TerminalIcon };
|
export { TerminalIcon };
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (<textarea data-slot="textarea" className={cn("border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
export { Textarea };
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
"use client";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export function useCover() {
|
|||||||
track: position,
|
track: position,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
if (playlistName && !isAlbum) {
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
|
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -129,7 +131,9 @@ export function useCover() {
|
|||||||
track: trackPosition,
|
track: trackPosition,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
if (playlistName && !isAlbum) {
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
|
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -44,7 +45,7 @@ export function useDownload() {
|
|||||||
const shouldStopDownloadRef = useRef(false);
|
const shouldStopDownloadRef = useRef(false);
|
||||||
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
const service = settings.downloader;
|
const service = settings.downloader;
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
let useAlbumTrackNumber = false;
|
let useAlbumTrackNumber = false;
|
||||||
@@ -82,7 +83,9 @@ export function useDownload() {
|
|||||||
year: yearValue,
|
year: yearValue,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
if (playlistName) {
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
|
if (playlistName && !useAlbumSubfolder) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -139,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) {
|
||||||
@@ -147,122 +150,146 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
if (streamingURLs?.tidal_url) {
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
try {
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const tidalResponse = await downloadTrack({
|
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
isrc,
|
const qobuzQuality = is24Bit ? "7" : "6";
|
||||||
service: "tidal",
|
for (const s of order) {
|
||||||
query,
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
track_name: trackName,
|
try {
|
||||||
artist_name: artistName,
|
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||||
album_name: albumName,
|
const response = await downloadTrack({
|
||||||
album_artist: albumArtist,
|
isrc,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
service: "tidal",
|
||||||
cover_url: coverUrl,
|
query,
|
||||||
output_dir: outputDir,
|
track_name: trackName,
|
||||||
filename_format: settings.filenameTemplate,
|
artist_name: artistName,
|
||||||
track_number: settings.trackNumber,
|
album_name: albumName,
|
||||||
position,
|
album_artist: albumArtist,
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
spotify_id: spotifyId,
|
cover_url: coverUrl,
|
||||||
embed_lyrics: settings.embedLyrics,
|
output_dir: outputDir,
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
filename_format: settings.filenameTemplate,
|
||||||
service_url: streamingURLs.tidal_url,
|
track_number: settings.trackNumber,
|
||||||
duration: durationSeconds,
|
position,
|
||||||
item_id: itemID,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
audio_format: settings.tidalQuality || "LOSSLESS",
|
spotify_id: spotifyId,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
embed_lyrics: settings.embedLyrics,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
service_url: streamingURLs.tidal_url,
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
duration: durationSeconds,
|
||||||
copyright: copyright,
|
item_id: itemID,
|
||||||
publisher: publisher,
|
audio_format: tidalQuality,
|
||||||
});
|
spotify_track_number: spotifyTrackNumber,
|
||||||
if (tidalResponse.success) {
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
return tidalResponse;
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
lastResponse = response;
|
||||||
|
logger.warning(`tidal failed, trying next...`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`tidal error: ${err}`);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
logger.warning(`tidal failed, trying amazon...`);
|
|
||||||
}
|
}
|
||||||
catch (tidalErr) {
|
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||||
logger.error(`tidal error: ${tidalErr}`);
|
try {
|
||||||
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||||
|
const response = await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: "amazon",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: albumArtist,
|
||||||
|
release_date: finalReleaseDate || releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
|
service_url: streamingURLs.amazon_url,
|
||||||
|
item_id: itemID,
|
||||||
|
spotify_track_number: spotifyTrackNumber,
|
||||||
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
lastResponse = response;
|
||||||
|
logger.warning(`amazon failed, trying next...`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`amazon error: ${err}`);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (s === "qobuz") {
|
||||||
|
try {
|
||||||
|
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
||||||
|
const response = await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: "qobuz",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: albumArtist,
|
||||||
|
release_date: finalReleaseDate || releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: trackNumberForTemplate,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
|
item_id: itemID,
|
||||||
|
audio_format: qobuzQuality,
|
||||||
|
spotify_track_number: spotifyTrackNumber,
|
||||||
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
lastResponse = response;
|
||||||
|
logger.warning(`qobuz failed, trying next...`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.error(`qobuz error: ${err}`);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (streamingURLs?.amazon_url) {
|
if (itemID) {
|
||||||
try {
|
|
||||||
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
|
||||||
const amazonResponse = await downloadTrack({
|
|
||||||
isrc,
|
|
||||||
service: "amazon",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: artistName,
|
|
||||||
album_name: albumName,
|
|
||||||
album_artist: albumArtist,
|
|
||||||
release_date: finalReleaseDate || releaseDate,
|
|
||||||
cover_url: coverUrl,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameTemplate,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position,
|
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
|
||||||
spotify_id: spotifyId,
|
|
||||||
embed_lyrics: settings.embedLyrics,
|
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
|
||||||
service_url: streamingURLs.amazon_url,
|
|
||||||
item_id: itemID,
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
|
||||||
copyright: copyright,
|
|
||||||
publisher: publisher,
|
|
||||||
});
|
|
||||||
if (amazonResponse.success) {
|
|
||||||
logger.success(`amazon: ${trackName} - ${artistName}`);
|
|
||||||
return amazonResponse;
|
|
||||||
}
|
|
||||||
logger.warning(`amazon failed, trying qobuz...`);
|
|
||||||
}
|
|
||||||
catch (amazonErr) {
|
|
||||||
logger.error(`amazon error: ${amazonErr}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
|
|
||||||
const qobuzResponse = await downloadTrack({
|
|
||||||
isrc,
|
|
||||||
service: "qobuz",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: artistName,
|
|
||||||
album_name: albumName,
|
|
||||||
album_artist: albumArtist,
|
|
||||||
release_date: finalReleaseDate || releaseDate,
|
|
||||||
cover_url: coverUrl,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameTemplate,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position: trackNumberForTemplate,
|
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
|
||||||
spotify_id: spotifyId,
|
|
||||||
embed_lyrics: settings.embedLyrics,
|
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
|
||||||
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
|
||||||
item_id: itemID,
|
|
||||||
audio_format: settings.qobuzQuality || "6",
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
|
||||||
copyright: copyright,
|
|
||||||
publisher: publisher,
|
|
||||||
});
|
|
||||||
if (!qobuzResponse.success && itemID) {
|
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
|
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
||||||
}
|
}
|
||||||
return qobuzResponse;
|
return lastResponse;
|
||||||
}
|
}
|
||||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
let audioFormat: string | undefined;
|
let audioFormat: string | undefined;
|
||||||
@@ -346,7 +373,9 @@ export function useDownload() {
|
|||||||
year: yearValue,
|
year: yearValue,
|
||||||
playlist: folderName?.replace(/\//g, placeholder),
|
playlist: folderName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
if (folderName && !isAlbum) {
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
|
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) {
|
||||||
@@ -364,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) {
|
||||||
@@ -372,115 +401,138 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
if (streamingURLs?.tidal_url) {
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
try {
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
const tidalResponse = await downloadTrack({
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
isrc,
|
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
service: "tidal",
|
const qobuzQuality = is24Bit ? "7" : "6";
|
||||||
query,
|
for (const s of order) {
|
||||||
track_name: trackName,
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
artist_name: artistName,
|
try {
|
||||||
album_name: albumName,
|
const response = await downloadTrack({
|
||||||
album_artist: albumArtist,
|
isrc,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
service: "tidal",
|
||||||
cover_url: coverUrl,
|
query,
|
||||||
output_dir: outputDir,
|
track_name: trackName,
|
||||||
filename_format: settings.filenameTemplate,
|
artist_name: artistName,
|
||||||
track_number: settings.trackNumber,
|
album_name: albumName,
|
||||||
position,
|
album_artist: albumArtist,
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
spotify_id: spotifyId,
|
cover_url: coverUrl,
|
||||||
embed_lyrics: settings.embedLyrics,
|
output_dir: outputDir,
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
filename_format: settings.filenameTemplate,
|
||||||
service_url: streamingURLs.tidal_url,
|
track_number: settings.trackNumber,
|
||||||
duration: durationSeconds,
|
position,
|
||||||
item_id: itemID,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
audio_format: settings.tidalQuality || "LOSSLESS",
|
spotify_id: spotifyId,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
embed_lyrics: settings.embedLyrics,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
service_url: streamingURLs.tidal_url,
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
duration: durationSeconds,
|
||||||
copyright: copyright,
|
item_id: itemID,
|
||||||
publisher: publisher,
|
audio_format: tidalQuality,
|
||||||
});
|
spotify_track_number: spotifyTrackNumber,
|
||||||
if (tidalResponse.success) {
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
return tidalResponse;
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
lastResponse = response;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Tidal error:", err);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (tidalErr) {
|
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||||
console.error("Tidal error:", tidalErr);
|
try {
|
||||||
}
|
const response = await downloadTrack({
|
||||||
}
|
isrc,
|
||||||
if (streamingURLs?.amazon_url) {
|
service: "amazon",
|
||||||
try {
|
query,
|
||||||
const amazonResponse = await downloadTrack({
|
track_name: trackName,
|
||||||
isrc,
|
artist_name: artistName,
|
||||||
service: "amazon",
|
album_name: albumName,
|
||||||
query,
|
album_artist: albumArtist,
|
||||||
track_name: trackName,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
artist_name: artistName,
|
cover_url: coverUrl,
|
||||||
album_name: albumName,
|
output_dir: outputDir,
|
||||||
album_artist: albumArtist,
|
filename_format: settings.filenameTemplate,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
track_number: settings.trackNumber,
|
||||||
cover_url: coverUrl,
|
position,
|
||||||
output_dir: outputDir,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
filename_format: settings.filenameTemplate,
|
spotify_id: spotifyId,
|
||||||
track_number: settings.trackNumber,
|
embed_lyrics: settings.embedLyrics,
|
||||||
position,
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
service_url: streamingURLs.amazon_url,
|
||||||
spotify_id: spotifyId,
|
item_id: itemID,
|
||||||
embed_lyrics: settings.embedLyrics,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
service_url: streamingURLs.amazon_url,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
item_id: itemID,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
copyright: copyright,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
publisher: publisher,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
});
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
if (response.success) {
|
||||||
copyright: copyright,
|
return response;
|
||||||
publisher: publisher,
|
}
|
||||||
});
|
lastResponse = response;
|
||||||
if (amazonResponse.success) {
|
}
|
||||||
return amazonResponse;
|
catch (err) {
|
||||||
|
console.error("Amazon error:", err);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (amazonErr) {
|
else if (s === "qobuz") {
|
||||||
console.error("Amazon error:", amazonErr);
|
try {
|
||||||
|
const response = await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: "qobuz",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: albumArtist,
|
||||||
|
release_date: finalReleaseDate || releaseDate,
|
||||||
|
cover_url: coverUrl,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: trackNumberForTemplate,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
|
duration: durationSeconds,
|
||||||
|
item_id: itemID,
|
||||||
|
audio_format: qobuzQuality,
|
||||||
|
spotify_track_number: spotifyTrackNumber,
|
||||||
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
|
copyright: copyright,
|
||||||
|
publisher: publisher,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
lastResponse = response;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Qobuz error:", err);
|
||||||
|
lastResponse = { success: false, error: String(err) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const qobuzResponse = await downloadTrack({
|
if (!lastResponse.success && itemID) {
|
||||||
isrc,
|
|
||||||
service: "qobuz",
|
|
||||||
query,
|
|
||||||
track_name: trackName,
|
|
||||||
artist_name: artistName,
|
|
||||||
album_name: albumName,
|
|
||||||
album_artist: albumArtist,
|
|
||||||
release_date: finalReleaseDate || releaseDate,
|
|
||||||
cover_url: coverUrl,
|
|
||||||
output_dir: outputDir,
|
|
||||||
filename_format: settings.filenameTemplate,
|
|
||||||
track_number: settings.trackNumber,
|
|
||||||
position: trackNumberForTemplate,
|
|
||||||
use_album_track_number: useAlbumTrackNumber,
|
|
||||||
spotify_id: spotifyId,
|
|
||||||
embed_lyrics: settings.embedLyrics,
|
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
|
||||||
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
|
||||||
item_id: itemID,
|
|
||||||
audio_format: settings.qobuzQuality || "6",
|
|
||||||
spotify_track_number: spotifyTrackNumber,
|
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
|
||||||
spotify_total_discs: spotifyTotalDiscs,
|
|
||||||
copyright: copyright,
|
|
||||||
publisher: publisher,
|
|
||||||
});
|
|
||||||
if (!qobuzResponse.success && itemID) {
|
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
|
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
||||||
}
|
}
|
||||||
return qobuzResponse;
|
return lastResponse;
|
||||||
}
|
}
|
||||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
let audioFormat: string | undefined;
|
let audioFormat: string | undefined;
|
||||||
@@ -575,7 +627,8 @@ export function useDownload() {
|
|||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
if (folderName && !isAlbum) {
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
|
if (folderName && (!isAlbum || !useAlbumTag)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
const selectedTrackObjects = selectedTracks
|
const selectedTrackObjects = selectedTracks
|
||||||
@@ -723,7 +776,8 @@ export function useDownload() {
|
|||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
if (folderName && !isAlbum) {
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
|
if (folderName && (!isAlbum || !useAlbumTag)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
logger.info(`checking existing files in parallel...`);
|
logger.info(`checking existing files in parallel...`);
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export function useLyrics() {
|
|||||||
track: position,
|
track: position,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
if (playlistName && !isAlbum) {
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
|
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -125,7 +127,9 @@ export function useLyrics() {
|
|||||||
track: trackPosition,
|
track: trackPosition,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
if (playlistName && !isAlbum) {
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
|
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
export function usePreview() {
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
|
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | 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) => {
|
||||||
|
try {
|
||||||
|
if (playingTrack === trackId && currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setPlayingTrack(null);
|
||||||
|
setCurrentAudio(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setCurrentAudio(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
}
|
||||||
|
setLoadingPreview(trackId);
|
||||||
|
const previewURL = await GetPreviewURL(trackId);
|
||||||
|
if (!previewURL) {
|
||||||
|
toast.error("Preview not available", {
|
||||||
|
description: `No preview found for "${trackName}"`,
|
||||||
|
});
|
||||||
|
setLoadingPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const audio = new Audio(previewURL);
|
||||||
|
audio.addEventListener("loadeddata", () => {
|
||||||
|
setLoadingPreview(null);
|
||||||
|
setPlayingTrack(trackId);
|
||||||
|
});
|
||||||
|
audio.addEventListener("ended", () => {
|
||||||
|
setPlayingTrack(null);
|
||||||
|
setCurrentAudio(null);
|
||||||
|
});
|
||||||
|
audio.addEventListener("error", () => {
|
||||||
|
toast.error("Failed to play preview", {
|
||||||
|
description: `Could not play preview for "${trackName}"`,
|
||||||
|
});
|
||||||
|
setLoadingPreview(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
setCurrentAudio(null);
|
||||||
|
});
|
||||||
|
setCurrentAudio(audio);
|
||||||
|
await audio.play();
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
console.error("Preview error:", error);
|
||||||
|
toast.error("Preview not available", {
|
||||||
|
description: error?.message || `Could not load preview for "${trackName}"`,
|
||||||
|
});
|
||||||
|
setLoadingPreview(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stopPreview = () => {
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio.currentTime = 0;
|
||||||
|
setCurrentAudio(null);
|
||||||
|
setPlayingTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
playPreview,
|
||||||
|
stopPreview,
|
||||||
|
loadingPreview,
|
||||||
|
playingTrack,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
--font-sans: "Bricolage Grotesque", "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -75,11 +76,15 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
code, pre, .font-mono {
|
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
.font-mono {
|
||||||
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,43 +139,51 @@
|
|||||||
/* Specific color for each toast type - match icon color */
|
/* Specific color for each toast type - match icon color */
|
||||||
[data-sonner-toast][data-type="success"] [data-description],
|
[data-sonner-toast][data-type="success"] [data-description],
|
||||||
[data-sonner-toast][data-type="success"] [data-description] * {
|
[data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
color: rgb(22 163 74) !important; /* green-600 - same as icon */
|
color: rgb(22 163 74) !important;
|
||||||
|
/* green-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sonner-toast][data-type="error"] [data-description],
|
[data-sonner-toast][data-type="error"] [data-description],
|
||||||
[data-sonner-toast][data-type="error"] [data-description] * {
|
[data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
color: rgb(220 38 38) !important; /* red-600 - same as icon */
|
color: rgb(220 38 38) !important;
|
||||||
|
/* red-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sonner-toast][data-type="warning"] [data-description],
|
[data-sonner-toast][data-type="warning"] [data-description],
|
||||||
[data-sonner-toast][data-type="warning"] [data-description] * {
|
[data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
|
color: rgb(202 138 4) !important;
|
||||||
|
/* yellow-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-sonner-toast][data-type="info"] [data-description],
|
[data-sonner-toast][data-type="info"] [data-description],
|
||||||
[data-sonner-toast][data-type="info"] [data-description] * {
|
[data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
|
color: rgb(37 99 235) !important;
|
||||||
|
/* blue-600 - same as icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode - use same icon colors */
|
/* Dark mode - use same icon colors */
|
||||||
.dark [data-sonner-toast][data-type="success"] [data-description],
|
.dark [data-sonner-toast][data-type="success"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="success"] [data-description] * {
|
.dark [data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
color: rgb(22 163 74) !important; /* green-600 */
|
color: rgb(22 163 74) !important;
|
||||||
|
/* green-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sonner-toast][data-type="error"] [data-description],
|
.dark [data-sonner-toast][data-type="error"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="error"] [data-description] * {
|
.dark [data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
color: rgb(220 38 38) !important; /* red-600 */
|
color: rgb(220 38 38) !important;
|
||||||
|
/* red-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sonner-toast][data-type="warning"] [data-description],
|
.dark [data-sonner-toast][data-type="warning"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
|
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
color: rgb(202 138 4) !important; /* yellow-600 */
|
color: rgb(202 138 4) !important;
|
||||||
|
/* yellow-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sonner-toast][data-type="info"] [data-description],
|
.dark [data-sonner-toast][data-type="info"] [data-description],
|
||||||
.dark [data-sonner-toast][data-type="info"] [data-description] * {
|
.dark [data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
color: rgb(37 99 235) !important; /* blue-600 */
|
color: rgb(37 99 235) !important;
|
||||||
|
/* blue-600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode toast styling */
|
/* Dark mode toast styling */
|
||||||
@@ -252,4 +265,4 @@
|
|||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.2);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ class Logger {
|
|||||||
const entry: LogEntry = {
|
const entry: LogEntry = {
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
level,
|
level,
|
||||||
message: message.toLowerCase(),
|
message: message,
|
||||||
};
|
};
|
||||||
this.logs.push(entry);
|
this.logs.push(entry);
|
||||||
if (this.logs.length > this.maxLogs) {
|
if (this.logs.length > this.maxLogs) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { GetDefaults } from "../../wailsjs/go/main/App";
|
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
|
||||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans";
|
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
||||||
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
||||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -21,8 +21,11 @@ export interface Settings {
|
|||||||
embedMaxQualityCover: boolean;
|
embedMaxQualityCover: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
qobuzQuality: "6" | "7" | "27";
|
qobuzQuality: "6" | "7";
|
||||||
amazonQuality: "HI_RES";
|
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";
|
||||||
|
autoQuality: "16" | "24";
|
||||||
|
allowFallback: boolean;
|
||||||
}
|
}
|
||||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -95,13 +98,17 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
operatingSystem: detectOS(),
|
operatingSystem: detectOS(),
|
||||||
tidalQuality: "LOSSLESS",
|
tidalQuality: "LOSSLESS",
|
||||||
qobuzQuality: "6",
|
qobuzQuality: "6",
|
||||||
amazonQuality: "HI_RES"
|
amazonQuality: "original",
|
||||||
|
autoOrder: "tidal-qobuz-amazon",
|
||||||
|
autoQuality: "16",
|
||||||
|
allowFallback: true
|
||||||
};
|
};
|
||||||
export const FONT_OPTIONS: {
|
export const FONT_OPTIONS: {
|
||||||
value: FontFamily;
|
value: FontFamily;
|
||||||
label: string;
|
label: string;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
|
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
|
||||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||||
@@ -137,7 +144,8 @@ async function fetchDefaultPath(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const SETTINGS_KEY = "spotiflac-settings";
|
const SETTINGS_KEY = "spotiflac-settings";
|
||||||
export function getSettings(): Settings {
|
let cachedSettings: Settings | null = null;
|
||||||
|
function getSettingsFromLocalStorage(): Settings {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
@@ -188,17 +196,117 @@ export function getSettings(): Settings {
|
|||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
|
if (parsed.qobuzQuality === "27") {
|
||||||
|
parsed.qobuzQuality = "6";
|
||||||
|
}
|
||||||
if (!('amazonQuality' in parsed)) {
|
if (!('amazonQuality' in parsed)) {
|
||||||
parsed.amazonQuality = "HI_RES";
|
parsed.amazonQuality = "original";
|
||||||
|
}
|
||||||
|
if (!('autoOrder' in parsed)) {
|
||||||
|
parsed.autoOrder = "tidal-qobuz-amazon";
|
||||||
|
}
|
||||||
|
if (!('autoQuality' in parsed)) {
|
||||||
|
parsed.autoQuality = "16";
|
||||||
|
}
|
||||||
|
if (!('allowFallback' in parsed)) {
|
||||||
|
parsed.allowFallback = true;
|
||||||
}
|
}
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to load settings:", error);
|
console.error("Failed to load settings from local storage:", error);
|
||||||
}
|
}
|
||||||
return DEFAULT_SETTINGS;
|
return DEFAULT_SETTINGS;
|
||||||
}
|
}
|
||||||
|
export function getSettings(): Settings {
|
||||||
|
if (cachedSettings)
|
||||||
|
return cachedSettings;
|
||||||
|
return getSettingsFromLocalStorage();
|
||||||
|
}
|
||||||
|
export async function loadSettings(): Promise<Settings> {
|
||||||
|
try {
|
||||||
|
const backendSettings = await LoadSettings();
|
||||||
|
if (backendSettings) {
|
||||||
|
const parsed = backendSettings as any;
|
||||||
|
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||||
|
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||||
|
delete parsed.darkMode;
|
||||||
|
}
|
||||||
|
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
||||||
|
const hasArtist = parsed.artistSubfolder;
|
||||||
|
const hasAlbum = parsed.albumSubfolder;
|
||||||
|
if (hasArtist && hasAlbum) {
|
||||||
|
parsed.folderPreset = "artist-album";
|
||||||
|
parsed.folderTemplate = "{artist}/{album}";
|
||||||
|
}
|
||||||
|
else if (hasArtist) {
|
||||||
|
parsed.folderPreset = "artist";
|
||||||
|
parsed.folderTemplate = "{artist}";
|
||||||
|
}
|
||||||
|
else if (hasAlbum) {
|
||||||
|
parsed.folderPreset = "album";
|
||||||
|
parsed.folderTemplate = "{album}";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parsed.folderPreset = "none";
|
||||||
|
parsed.folderTemplate = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
||||||
|
const format = parsed.filenameFormat;
|
||||||
|
if (format === "title-artist") {
|
||||||
|
parsed.filenamePreset = "artist-title";
|
||||||
|
parsed.filenameTemplate = "{artist} - {title}";
|
||||||
|
}
|
||||||
|
else if (format === "artist-title") {
|
||||||
|
parsed.filenamePreset = "artist-title";
|
||||||
|
parsed.filenameTemplate = "{artist} - {title}";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parsed.filenamePreset = "title";
|
||||||
|
parsed.filenameTemplate = "{title}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.operatingSystem = detectOS();
|
||||||
|
if (!('tidalQuality' in parsed)) {
|
||||||
|
parsed.tidalQuality = "LOSSLESS";
|
||||||
|
}
|
||||||
|
if (!('qobuzQuality' in parsed)) {
|
||||||
|
parsed.qobuzQuality = "6";
|
||||||
|
}
|
||||||
|
if (parsed.qobuzQuality === "27") {
|
||||||
|
parsed.qobuzQuality = "6";
|
||||||
|
}
|
||||||
|
if (!('amazonQuality' in parsed)) {
|
||||||
|
parsed.amazonQuality = "original";
|
||||||
|
}
|
||||||
|
if (!('autoOrder' in parsed)) {
|
||||||
|
parsed.autoOrder = "tidal-qobuz-amazon";
|
||||||
|
}
|
||||||
|
if (!('autoQuality' in parsed)) {
|
||||||
|
parsed.autoQuality = "16";
|
||||||
|
}
|
||||||
|
if (!('allowFallback' in parsed)) {
|
||||||
|
parsed.allowFallback = true;
|
||||||
|
}
|
||||||
|
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
return cachedSettings!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to load settings from backend:", error);
|
||||||
|
}
|
||||||
|
const local = getSettingsFromLocalStorage();
|
||||||
|
try {
|
||||||
|
await SaveToBackend(local as any);
|
||||||
|
cachedSettings = local;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to migrate settings to backend:", error);
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}
|
||||||
export interface TemplateData {
|
export interface TemplateData {
|
||||||
artist?: string;
|
artist?: string;
|
||||||
album?: string;
|
album?: string;
|
||||||
@@ -224,30 +332,33 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
export async function getSettingsWithDefaults(): Promise<Settings> {
|
export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||||
const settings = getSettings();
|
const settings = await loadSettings();
|
||||||
if (!settings.downloadPath) {
|
if (!settings.downloadPath) {
|
||||||
settings.downloadPath = await fetchDefaultPath();
|
settings.downloadPath = await fetchDefaultPath();
|
||||||
|
await saveSettings(settings);
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
export function saveSettings(settings: Settings): void {
|
export async function saveSettings(settings: Settings): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
cachedSettings = settings;
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
await SaveToBackend(settings as any);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to save settings:", error);
|
console.error("Failed to save settings:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function updateSettings(partial: Partial<Settings>): Settings {
|
export async function updateSettings(partial: Partial<Settings>): Promise<Settings> {
|
||||||
const current = getSettings();
|
const current = getSettings();
|
||||||
const updated = { ...current, ...partial };
|
const updated = { ...current, ...partial };
|
||||||
saveSettings(updated);
|
await saveSettings(updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||||
const defaultPath = await fetchDefaultPath();
|
const defaultPath = await fetchDefaultPath();
|
||||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||||
saveSettings(defaultSettings);
|
await saveSettings(defaultSettings);
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
}
|
}
|
||||||
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -45,6 +46,7 @@ export interface AlbumResponse {
|
|||||||
track_list: TrackMetadata[];
|
track_list: TrackMetadata[];
|
||||||
}
|
}
|
||||||
export interface PlaylistInfo {
|
export interface PlaylistInfo {
|
||||||
|
name: string;
|
||||||
tracks: {
|
tracks: {
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import path from "path"
|
import path from "path";
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import fs from "fs";
|
||||||
import react from "@vitejs/plugin-react"
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite"
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
// https://vite.dev/config/
|
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: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
define: {
|
||||||
})
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ require (
|
|||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
github.com/mewkiz/flac v1.0.13
|
github.com/mewkiz/flac v1.0.13
|
||||||
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
go.etcd.io/bbolt v1.4.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
|||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||||
@@ -57,11 +60,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
@@ -79,6 +86,8 @@ github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4X
|
|||||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
@@ -92,6 +101,8 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
|||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func main() {
|
|||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
|
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
OnShutdown: app.shutdown,
|
||||||
DragAndDrop: &options.DragAndDrop{
|
DragAndDrop: &options.DragAndDrop{
|
||||||
EnableFileDrop: true,
|
EnableFileDrop: true,
|
||||||
DisableWebViewDrop: false,
|
DisableWebViewDrop: false,
|
||||||
|
|||||||
@@ -12,9 +12,8 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.0.1",
|
"productVersion": "7.0.7",
|
||||||
"copyright": "© 2026 afkarxyz",
|
"copyright": "© 2026 afkarxyz"
|
||||||
"comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required."
|
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||