Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df56049db2 | |||
| 36a77ad8d1 | |||
| 71bce5d33e | |||
| b74dec7369 | |||
| d5c5f34d4c | |||
| 27be5c1b91 | |||
| 0c41d72ab2 | |||
| 25233349b9 | |||
| e04f6e4fdd | |||
| 24bcc56a8f | |||
| 45ad82bb66 | |||
| 13fcb5787d | |||
| 556e720574 | |||
| 791553bdc0 | |||
| 9361c608ca | |||
| 12729e2ca1 | |||
| b620112886 | |||
| cc1c80d367 | |||
| 63149c91a2 | |||
| 1e99d8b5c6 | |||
| b160d3c790 | |||
| d9cf5a5361 |
@@ -0,0 +1,3 @@
|
||||
github: afkarxyz
|
||||
ko_fi: afkarxyz
|
||||
buy_me_a_coffee: afkarxyz
|
||||
@@ -1,6 +1,6 @@
|
||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
|
||||
<!--  -->
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||

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

|
||||

|
||||
|
||||
## Other projects
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
|
||||
### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next)
|
||||
|
||||
Get Spotify tracks in Hi-Res lossless FLACs — no account required.
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
|
||||
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
|
||||
|
||||
### [SpotubeDL](https://spotubedl.com)
|
||||
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
|
||||
|
||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||
|
||||
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)
|
||||
[](https://www.buymeacoffee.com/afkarxyz)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -44,12 +86,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.
|
||||
|
||||
You are solely responsible for:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"spotiflac/backend"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -31,6 +33,14 @@ func NewApp() *App {
|
||||
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
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 {
|
||||
@@ -69,6 +79,9 @@ type DownloadRequest struct {
|
||||
SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"`
|
||||
Copyright string `json:"copyright,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 {
|
||||
@@ -80,14 +93,14 @@ type DownloadResponse struct {
|
||||
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 == "" {
|
||||
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()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID)
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, region)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -115,6 +128,27 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second)))
|
||||
defer cancel()
|
||||
|
||||
settings, err := a.LoadSettings()
|
||||
|
||||
if err == nil && settings != nil {
|
||||
if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
|
||||
if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
|
||||
|
||||
data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
||||
@@ -191,7 +225,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
req.OutputDir = "."
|
||||
} 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 == "" {
|
||||
@@ -271,7 +310,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||
@@ -291,8 +330,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
case "amazon":
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
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.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.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)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -300,15 +338,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "Spotify ID is required for Amazon Music",
|
||||
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
||||
}
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
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)
|
||||
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)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -316,14 +353,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "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)
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||
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)
|
||||
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)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -331,8 +366,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +408,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "ISRC is required for Qobuz (could not fetch from Deezer)",
|
||||
}, 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:
|
||||
return DownloadResponse{
|
||||
@@ -471,6 +505,46 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
|
||||
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{
|
||||
@@ -539,11 +613,113 @@ func (a *App) CancelAllQueuedItems() {
|
||||
backend.CancelAllQueuedItems()
|
||||
}
|
||||
|
||||
func (a *App) ExportFailedDownloads() (string, error) {
|
||||
queueInfo := backend.GetDownloadQueue()
|
||||
var failedItems []string
|
||||
|
||||
hasFailed := false
|
||||
for _, item := range queueInfo.Queue {
|
||||
if item.Status == backend.StatusFailed {
|
||||
hasFailed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFailed {
|
||||
return "No failed downloads to export.", nil
|
||||
}
|
||||
|
||||
failedItems = append(failedItems, fmt.Sprintf("Failed Downloads Report - %s", time.Now().Format("2006-01-02 15:04:05")))
|
||||
failedItems = append(failedItems, strings.Repeat("-", 50))
|
||||
failedItems = append(failedItems, "")
|
||||
|
||||
count := 0
|
||||
for _, item := range queueInfo.Queue {
|
||||
if item.Status == backend.StatusFailed {
|
||||
count++
|
||||
line := fmt.Sprintf("%d. %s - %s", count, item.TrackName, item.ArtistName)
|
||||
if item.AlbumName != "" {
|
||||
line += fmt.Sprintf(" (%s)", item.AlbumName)
|
||||
}
|
||||
failedItems = append(failedItems, line)
|
||||
failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage))
|
||||
|
||||
if item.ISRC != "" {
|
||||
failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.ISRC))
|
||||
if !strings.HasPrefix(item.ISRC, "http") {
|
||||
failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.ISRC))
|
||||
}
|
||||
}
|
||||
failedItems = append(failedItems, "")
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.Join(failedItems, "\n")
|
||||
defaultFilename := fmt.Sprintf("SpotiFLAC_%s_Failed.txt", time.Now().Format("20060102_150405"))
|
||||
|
||||
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
DefaultFilename: defaultFilename,
|
||||
Title: "Export Failed Downloads",
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Text Files (*.txt)",
|
||||
Pattern: "*.txt",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open save dialog: %v", err)
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return "Export cancelled", nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
|
||||
}
|
||||
|
||||
func (a *App) 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) {
|
||||
if filePath == "" {
|
||||
return "", fmt.Errorf("file path is required")
|
||||
@@ -935,6 +1111,27 @@ func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||
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) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
@@ -974,6 +1171,7 @@ type CheckFileExistenceRequest struct {
|
||||
FilenameFormat string `json:"filename_format,omitempty"`
|
||||
IncludeTrackNumber bool `json:"include_track_number,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
RelativePath string `json:"relative_path,omitempty"`
|
||||
}
|
||||
|
||||
type CheckFileExistenceResult struct {
|
||||
@@ -984,12 +1182,15 @@ type CheckFileExistenceResult struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
|
||||
func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
|
||||
if len(tracks) == 0 {
|
||||
return []CheckFileExistenceResult{}
|
||||
}
|
||||
|
||||
outputDir = backend.NormalizePath(outputDir)
|
||||
if rootDir != "" {
|
||||
rootDir = backend.NormalizePath(rootDir)
|
||||
}
|
||||
|
||||
defaultFilenameFormat := "title-artist"
|
||||
|
||||
@@ -1000,6 +1201,30 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
||||
|
||||
resultsChan := make(chan result, len(tracks))
|
||||
|
||||
var rootDirFiles map[string]string
|
||||
rootDirFilesOnce := false
|
||||
getRootDirFiles := func() map[string]string {
|
||||
if rootDirFilesOnce {
|
||||
return rootDirFiles
|
||||
}
|
||||
rootDirFiles = make(map[string]string)
|
||||
if rootDir != "" && rootDir != outputDir {
|
||||
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
|
||||
rootDirFiles[info.Name()] = path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
rootDirFilesOnce = true
|
||||
return rootDirFiles
|
||||
}
|
||||
|
||||
for i, track := range tracks {
|
||||
go func(idx int, t CheckFileExistenceRequest) {
|
||||
res := CheckFileExistenceResult{
|
||||
@@ -1036,6 +1261,8 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
||||
t.AlbumArtist,
|
||||
t.ReleaseDate,
|
||||
filenameFormat,
|
||||
"",
|
||||
"",
|
||||
t.IncludeTrackNumber,
|
||||
trackNumber,
|
||||
t.DiscNumber,
|
||||
@@ -1044,11 +1271,19 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
||||
|
||||
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 {
|
||||
res.Exists = true
|
||||
res.FilePath = expectedPath
|
||||
} else {
|
||||
|
||||
res.FilePath = expectedFilename
|
||||
}
|
||||
|
||||
resultsChan <- result{index: idx, result: res}
|
||||
@@ -1056,9 +1291,39 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
||||
}
|
||||
|
||||
results := make([]CheckFileExistenceResult, len(tracks))
|
||||
missingIndices := []int{}
|
||||
|
||||
for i := 0; i < len(tracks); i++ {
|
||||
r := <-resultsChan
|
||||
results[r.index] = r.result
|
||||
if !results[r.index].Exists {
|
||||
missingIndices = append(missingIndices, r.index)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingIndices) > 0 && rootDir != "" {
|
||||
filesMap := getRootDirFiles()
|
||||
if len(filesMap) > 0 {
|
||||
for _, idx := range missingIndices {
|
||||
|
||||
expectedFilename := results[idx].FilePath
|
||||
baseName := filepath.Base(expectedFilename)
|
||||
if path, ok := filesMap[baseName]; ok {
|
||||
results[idx].Exists = true
|
||||
results[idx].FilePath = path
|
||||
} else {
|
||||
results[idx].FilePath = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, idx := range missingIndices {
|
||||
results[idx].FilePath = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, idx := range missingIndices {
|
||||
results[idx].FilePath = ""
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -1127,3 +1392,56 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
|
||||
func (a *App) CheckFFmpegInstalled() (bool, error) {
|
||||
return backend.IsFFmpegInstalled()
|
||||
}
|
||||
|
||||
func (a *App) GetOSInfo() (string, error) {
|
||||
return backend.GetOSInfo()
|
||||
}
|
||||
|
||||
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
|
||||
if len(filePaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fnName := m3u8Name
|
||||
|
||||
safeName := backend.SanitizeFilename(fnName)
|
||||
if safeName == "" {
|
||||
safeName = "playlist"
|
||||
}
|
||||
|
||||
m3u8Path := filepath.Join(outputDir, safeName+".m3u8")
|
||||
|
||||
f, err := os.Create(m3u8Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.WriteString("#EXTM3U\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, path := range filePaths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(outputDir, path)
|
||||
if err != nil {
|
||||
|
||||
relPath = path
|
||||
}
|
||||
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
if _, err := f.WriteString(relPath + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -19,11 +16,8 @@ import (
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
client *http.Client
|
||||
regions []string
|
||||
}
|
||||
|
||||
type SongLinkResponse struct {
|
||||
@@ -32,35 +26,9 @@ type SongLinkResponse struct {
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type DoubleDoubleStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
FriendlyStatus string `json:"friendlyStatus"`
|
||||
URL string `json:"url"`
|
||||
Current struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
type LucidaLoadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Server string `json:"server"`
|
||||
Handoff string `json:"handoff"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type LucidaStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Progress struct {
|
||||
Current int64 `json:"current"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"progress"`
|
||||
type AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
@@ -68,93 +36,36 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
client: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
},
|
||||
regions: []string{"us", "eu"},
|
||||
apiCallResetTime: time.Now(),
|
||||
regions: []string{"us", "eu"},
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = now
|
||||
}
|
||||
spotifyBase := "https://open.spotify.com/track/"
|
||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
||||
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
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))
|
||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
||||
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
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")
|
||||
|
||||
fmt.Println("Getting Amazon URL...")
|
||||
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
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
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
@@ -194,374 +105,163 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) extractData(html string, patterns []string) string {
|
||||
for _, p := range patterns {
|
||||
re := regexp.MustCompile(p)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
||||
|
||||
func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality string) (string, error) {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Jar: jar,
|
||||
Timeout: 120 * time.Second,
|
||||
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
|
||||
asin := asinRegex.FindString(amazonURL)
|
||||
if asin == "" {
|
||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||
}
|
||||
|
||||
userAgent := a.getRandomUserAgent()
|
||||
|
||||
fmt.Printf("Initializing lucida for Amazon Music... (Target: %s)\n", amazonURL)
|
||||
lucidaBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vP3VybD0lcyZjb3VudHJ5PWF1dG8=")
|
||||
lucidaURL := fmt.Sprintf(string(lucidaBase), url.QueryEscape(amazonURL))
|
||||
req, _ := http.NewRequest("GET", lucidaURL, nil)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
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")
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
html := string(bodyBytes)
|
||||
|
||||
token := a.extractData(html, []string{`token:"([^"]+)"`, `"token"\s*:\s*"([^"]+)"`})
|
||||
streamURL := a.extractData(html, []string{`"url":"([^"]+)"`, `url:"([^"]+)"`})
|
||||
expiry := a.extractData(html, []string{`tokenExpiry:(\d+)`, `"tokenExpiry"\s*:\s*(\d+)`})
|
||||
|
||||
if token == "" || streamURL == "" {
|
||||
errorMsg := a.extractData(html, []string{`error:"([^"]+)"`, `"error"\s*:\s*"([^"]+)"`})
|
||||
if errorMsg != "" {
|
||||
return "", fmt.Errorf("lucida error: %s", errorMsg)
|
||||
}
|
||||
return "", fmt.Errorf("could not extract required data from lucida")
|
||||
}
|
||||
|
||||
decodedToken := token
|
||||
if secondBase64, err := base64.StdEncoding.DecodeString(token); err == nil {
|
||||
if firstBase64, err := base64.StdEncoding.DecodeString(string(secondBase64)); err == nil {
|
||||
decodedToken = string(firstBase64)
|
||||
}
|
||||
}
|
||||
|
||||
streamURL = strings.ReplaceAll(streamURL, `\/`, `/`)
|
||||
fmt.Printf("Fetching Amazon stream via Lucida...\n")
|
||||
|
||||
loadPayload := map[string]interface{}{
|
||||
"account": map[string]string{"id": "auto", "type": "country"},
|
||||
"compat": "false", "downscale": "original", "handoff": true,
|
||||
"metadata": true, "private": true,
|
||||
"token": map[string]interface{}{"primary": decodedToken, "expiry": expiry},
|
||||
"upload": map[string]bool{"enabled": false},
|
||||
"url": streamURL,
|
||||
}
|
||||
|
||||
payloadBytes, _ := json.Marshal(loadPayload)
|
||||
loadAPI, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vYXBpL2xvYWQ/dXJsPS9hcGkvZmV0Y2gvc3RyZWFtL3Yy")
|
||||
req, _ = http.NewRequest("POST", string(loadAPI), bytes.NewBuffer(payloadBytes))
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, cookie := range client.Jar.Cookies(req.URL) {
|
||||
if cookie.Name == "csrf_token" {
|
||||
req.Header.Set("X-CSRF-Token", cookie.Value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var loadData LucidaLoadResponse
|
||||
json.NewDecoder(resp.Body).Decode(&loadData)
|
||||
|
||||
if !loadData.Success {
|
||||
return "", fmt.Errorf("lucida load request failed: %s", loadData.Error)
|
||||
}
|
||||
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
||||
completionBase, _ := base64.StdEncoding.DecodeString("Lmx1Y2lkYS50by9hcGkvZmV0Y2gvcmVxdWVzdC8=")
|
||||
completionURL := fmt.Sprintf("%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff)
|
||||
fmt.Println("Processing on Lucida server...")
|
||||
|
||||
var finalStatus LucidaStatusResponse
|
||||
for {
|
||||
req, _ = http.NewRequest("GET", completionURL, nil)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
json.NewDecoder(resp.Body).Decode(&finalStatus)
|
||||
resp.Body.Close()
|
||||
|
||||
if finalStatus.Status == "completed" {
|
||||
fmt.Println("\nTrack processing completed!")
|
||||
break
|
||||
} else if finalStatus.Status == "error" {
|
||||
return "", fmt.Errorf("lucida processing failed: %s", finalStatus.Message)
|
||||
} else if finalStatus.Progress.Total > 0 {
|
||||
percent := (finalStatus.Progress.Current * 100) / finalStatus.Progress.Total
|
||||
fmt.Printf("\rLucida Progress: %d%%", percent)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
downloadSuffix, _ := base64.StdEncoding.DecodeString("L2Rvd25sb2Fk")
|
||||
downloadURL := fmt.Sprintf("%s%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff, string(downloadSuffix))
|
||||
req, _ = http.NewRequest("GET", downloadURL, nil)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err = client.Do(req)
|
||||
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("lucida download failed with status %d", resp.StatusCode)
|
||||
return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fileName := "track.flac"
|
||||
contentDisp := resp.Header.Get("Content-Disposition")
|
||||
if contentDisp != "" {
|
||||
re := regexp.MustCompile(`filename[*]?=([^;]+)`)
|
||||
if matches := re.FindStringSubmatch(contentDisp); len(matches) > 1 {
|
||||
rawName := strings.Trim(matches[1], `"'`)
|
||||
if strings.HasPrefix(rawName, "UTF-8''") {
|
||||
decodedName, _ := url.PathUnescape(rawName[7:])
|
||||
fileName = decodedName
|
||||
} else {
|
||||
fileName = rawName
|
||||
}
|
||||
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
fileName = reg.ReplaceAllString(fileName, "")
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.StreamURL == "" {
|
||||
return "", fmt.Errorf("no stream URL found in response")
|
||||
}
|
||||
|
||||
downloadURL := apiResp.StreamURL
|
||||
fileName := fmt.Sprintf("%s.m4a", asin)
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
fmt.Printf("Downloading from Lucida: %s\n", fileName)
|
||||
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
||||
dlReq.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")
|
||||
|
||||
dlResp, err := a.client.Do(dlReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
fmt.Printf("Downloading track: %s\n", fileName)
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
_, err = io.Copy(pw, dlResp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(filePath)
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
|
||||
if apiResp.DecryptionKey != "" {
|
||||
fmt.Printf("Decrypting file...\n")
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
var codec string
|
||||
if err == nil {
|
||||
cmdProbe := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
filePath,
|
||||
)
|
||||
setHideWindow(cmdProbe)
|
||||
codecOutput, _ := cmdProbe.Output()
|
||||
codec = strings.TrimSpace(string(codecOutput))
|
||||
fmt.Printf("Detected codec: %s\n", codec)
|
||||
}
|
||||
|
||||
targetExt := ".m4a"
|
||||
if codec == "flac" {
|
||||
targetExt = ".flac"
|
||||
}
|
||||
|
||||
decryptedFilename := "dec_" + fileName + targetExt
|
||||
|
||||
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
|
||||
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
|
||||
}
|
||||
|
||||
decryptedPath := filepath.Join(outputDir, decryptedFilename)
|
||||
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(apiResp.DecryptionKey)
|
||||
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-decryption_key", key,
|
||||
"-i", filePath,
|
||||
"-c", "copy",
|
||||
"-y",
|
||||
decryptedPath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
outStr := string(output)
|
||||
if len(outStr) > 500 {
|
||||
outStr = outStr[len(outStr)-500:]
|
||||
}
|
||||
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
|
||||
}
|
||||
|
||||
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
|
||||
return "", fmt.Errorf("decrypted file missing or empty")
|
||||
}
|
||||
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
|
||||
if err := os.Rename(decryptedPath, finalPath); err != nil {
|
||||
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
|
||||
}
|
||||
filePath = finalPath
|
||||
|
||||
fmt.Println("Decryption successful")
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
||||
fmt.Println("Attempting download via Lucida (Priority)...")
|
||||
filePath, err := a.DownloadFromLucida(amazonURL, outputDir, quality)
|
||||
if err == nil {
|
||||
return filePath, nil
|
||||
}
|
||||
fmt.Printf("Lucida failed: %v\nTrying Double-Double as fallback...\n", err)
|
||||
|
||||
var lastError error
|
||||
lastError = err
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("\nTrying region: %s...\n", region)
|
||||
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fmt.Println("Submitting download request...")
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var submitResp DoubleDoubleSubmitResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !submitResp.Success || submitResp.ID == "" {
|
||||
lastError = fmt.Errorf("submit request failed")
|
||||
continue
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("Download ID: %s\n", downloadID)
|
||||
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("Waiting for download to complete...")
|
||||
|
||||
maxWait := 300 * time.Second
|
||||
elapsed := time.Duration(0)
|
||||
pollInterval := 3 * time.Second
|
||||
|
||||
for elapsed < maxWait {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed += pollInterval
|
||||
|
||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
statusResp, err := a.client.Do(statusReq)
|
||||
if err != nil {
|
||||
fmt.Printf("\rStatus check failed, retrying...")
|
||||
continue
|
||||
}
|
||||
|
||||
if statusResp.StatusCode != 200 {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var status DoubleDoubleStatusResponse
|
||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\rInvalid JSON response, retrying...")
|
||||
continue
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\nDownload ready!")
|
||||
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
} else if strings.HasPrefix(fileURL, "/") {
|
||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
|
||||
|
||||
downloadReq, err := http.NewRequest("GET", fileURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create download request: %w", err)
|
||||
break
|
||||
}
|
||||
|
||||
downloadReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fileResp, err := a.client.Do(downloadReq)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to download file: %w", err)
|
||||
break
|
||||
}
|
||||
defer fileResp.Body.Close()
|
||||
|
||||
if fileResp.StatusCode != 200 {
|
||||
lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode)
|
||||
break
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
|
||||
for _, char := range `<>:"/\|?*` {
|
||||
fileName = strings.ReplaceAll(fileName, string(char), "")
|
||||
}
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create file: %w", err)
|
||||
break
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
fmt.Println("Downloading...")
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, fileResp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
fmt.Println("Download complete!")
|
||||
return filePath, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
errorMsg := status.FriendlyStatus
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error"
|
||||
}
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
}
|
||||
fmt.Printf("\r%s...", friendlyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= maxWait {
|
||||
lastError = fmt.Errorf("download timeout")
|
||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -570,7 +270,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -586,6 +286,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
return "", err
|
||||
}
|
||||
|
||||
originalFileDir := filepath.Dir(filePath)
|
||||
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
@@ -637,7 +340,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
}
|
||||
}
|
||||
|
||||
newFilename = newFilename + ".flac"
|
||||
ext := filepath.Ext(filePath)
|
||||
if ext == "" {
|
||||
ext = ".flac"
|
||||
}
|
||||
newFilename = newFilename + ext
|
||||
newFilePath := filepath.Join(outputDir, newFilename)
|
||||
|
||||
if err := os.Rename(filePath, newFilePath); err != nil {
|
||||
@@ -685,23 +392,35 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
|
||||
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata embedded successfully")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
|
||||
|
||||
originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
|
||||
if _, err := os.Stat(originalM4aPath); err == nil {
|
||||
if err := os.Remove(originalM4aPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
||||
}
|
||||
|
||||
@@ -162,3 +162,46 @@ func GetFileSize(filepath string) (int64, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -91,7 +91,17 @@ func GetFFmpegPath() (string, error) {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
return filepath.Join(ffmpegDir, ffmpegName), nil
|
||||
localPath := filepath.Join(ffmpegDir, ffmpegName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(ffmpegName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
func GetFFprobePath() (string, error) {
|
||||
@@ -105,12 +115,17 @@ func GetFFprobePath() (string, error) {
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(ffprobePath); err == nil {
|
||||
return ffprobePath, nil
|
||||
localPath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffprobe not found in app directory")
|
||||
path, err := exec.LookPath(ffprobeName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
|
||||
}
|
||||
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
|
||||
@@ -9,12 +9,15 @@ import (
|
||||
"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)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
safeTitle := SanitizeFilename(trackName)
|
||||
safeArtist := SanitizeFilename(artistName)
|
||||
safeAlbum := SanitizeFilename(albumName)
|
||||
safeAlbumArtist := SanitizeFilename(albumArtist)
|
||||
|
||||
safePlaylist := SanitizeFilename(playlistName)
|
||||
safeCreator := SanitizeFilename(playlistOwner)
|
||||
|
||||
year := ""
|
||||
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_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
||||
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
@@ -64,7 +69,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
func SanitizeFilename(name string) string {
|
||||
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
@@ -148,7 +153,8 @@ func SanitizeFolderPath(folderPath string) string {
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -78,9 +78,8 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
||||
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
|
||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
|
||||
|
||||
resp, err := q.client.Get(url)
|
||||
if err != nil {
|
||||
@@ -119,104 +118,204 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
|
||||
qualityCode := quality
|
||||
if qualityCode == "" {
|
||||
qualityCode = "6"
|
||||
func decodeXOR(data []byte) string {
|
||||
text := string(data)
|
||||
runes := []rune(text)
|
||||
result := make([]rune, len(runes))
|
||||
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)
|
||||
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit\n")
|
||||
|
||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||
|
||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||
fmt.Printf("Trying Primary API: %s\n", primaryURL)
|
||||
|
||||
resp, err := q.client.Get(primaryURL)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
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()
|
||||
func (q *QobuzDownloader) mapJumoQuality(quality string) int {
|
||||
switch quality {
|
||||
case "6":
|
||||
return 6
|
||||
case "7":
|
||||
return 7
|
||||
case "27":
|
||||
return 27
|
||||
default:
|
||||
return 6
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Primary API failed, trying Fallback API #1...")
|
||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
|
||||
formatID := q.mapJumoQuality(quality)
|
||||
region := "US"
|
||||
url := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||
|
||||
resp, err = q.client.Get(fallbackURL)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
defer resp.Body.Close()
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err == nil && len(body) > 0 {
|
||||
fmt.Printf("Fallback API #1 response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
fmt.Printf("✓ Got download URL from Fallback API #1\n")
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
fmt.Println("Fallback API #1 failed, trying Fallback API #2...")
|
||||
fallback2Base, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9xb2J1ei5zcXVpZC53dGYvYXBpL2Rvd25sb2FkLW11c2ljP3RyYWNrX2lkPQ==")
|
||||
fallback2URL := fmt.Sprintf("%s%d&quality=%s", string(fallback2Base), trackID, qualityCode)
|
||||
|
||||
resp, err = q.client.Get(fallback2URL)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("all APIs failed to get download URL: %w", err)
|
||||
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("Referer", "https://jumo-dl.pages.dev/")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body))
|
||||
return "", fmt.Errorf("all APIs returned non-200 status")
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
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 result.URL != "" {
|
||||
return result.URL, 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 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
return "", fmt.Errorf("empty body")
|
||||
}
|
||||
|
||||
fmt.Printf("Fallback API #2 response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
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)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
var nestedResp struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
||||
return nestedResp.Data.URL, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid response")
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
return "", fmt.Errorf("no download URL available from any API")
|
||||
url, err := downloadFunc(qualityCode)
|
||||
if err == nil {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Got download URL from Fallback API #2\n")
|
||||
return streamResp.URL, nil
|
||||
currentQuality := qualityCode
|
||||
|
||||
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 {
|
||||
@@ -334,7 +433,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
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)
|
||||
|
||||
if outputDir != "." {
|
||||
@@ -362,7 +461,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||
|
||||
fmt.Println("Getting download URL...")
|
||||
downloadURL, err := q.GetDownloadURL(track.ID, quality)
|
||||
downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
@@ -76,6 +76,10 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
|
||||
@@ -112,7 +112,7 @@ func (c *SpotifyClient) getAccessToken() error {
|
||||
q.Add("totpServer", totpCode)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
@@ -149,7 +149,7 @@ func (c *SpotifyClient) getSessionInfo() error {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||
|
||||
for name, value := range c.cookies {
|
||||
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
||||
@@ -230,7 +230,7 @@ func (c *SpotifyClient) getClientToken() error {
|
||||
req.Header.Set("Authority", "clienttoken.spotify.com")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -288,7 +288,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf
|
||||
req.Header.Set("Client-Token", c.clientToken)
|
||||
req.Header.Set("Spotify-App-Version", c.clientVersion)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -714,6 +714,17 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
}
|
||||
}
|
||||
|
||||
if albumArtistsString == "" {
|
||||
albumArtists := extractArtists(getMap(albumData, "artists"))
|
||||
if len(albumArtists) > 0 {
|
||||
albumArtistNames := []string{}
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
albumInfo = map[string]interface{}{
|
||||
"id": albumID,
|
||||
"name": getString(albumData, "name"),
|
||||
@@ -756,23 +767,76 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
if discNumber == 0 {
|
||||
discNumber = 1
|
||||
}
|
||||
|
||||
maxDiscFromAlbum := 0
|
||||
totalDiscsFromAlbum := 0
|
||||
|
||||
if len(albumFetchData) > 0 && albumFetchData[0] != nil {
|
||||
albumUnion := getMap(getMap(albumFetchData[0], "data"), "albumUnion")
|
||||
if len(albumUnion) > 0 {
|
||||
discsData := getMap(albumUnion, "discs")
|
||||
if len(discsData) > 0 {
|
||||
totalDiscsFromAlbum = int(getFloat64(discsData, "totalCount"))
|
||||
}
|
||||
|
||||
albumTracks := getMap(albumUnion, "tracks")
|
||||
if len(albumTracks) > 0 {
|
||||
albumTrackItems := getSlice(albumTracks, "items")
|
||||
currentTrackID := getString(trackData, "id")
|
||||
for idx, item := range albumTrackItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trackItem := getMap(itemMap, "track")
|
||||
if len(trackItem) > 0 {
|
||||
dNum := int(getFloat64(trackItem, "discNumber"))
|
||||
if dNum > maxDiscFromAlbum {
|
||||
maxDiscFromAlbum = dNum
|
||||
}
|
||||
|
||||
trackURI := getString(trackItem, "uri")
|
||||
if strings.Contains(trackURI, currentTrackID) || getString(trackItem, "id") == currentTrackID {
|
||||
if dNum > 0 {
|
||||
discNumber = dNum
|
||||
}
|
||||
}
|
||||
|
||||
trackNum := int(getFloat64(trackData, "trackNumber"))
|
||||
itemTrackNum := idx + 1
|
||||
if trackNum == itemTrackNum && dNum > 0 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalDiscs := 1
|
||||
if discInfo["totalDiscs"] != nil {
|
||||
if totalDiscsFromAlbum > 0 {
|
||||
totalDiscs = totalDiscsFromAlbum
|
||||
} else if maxDiscFromAlbum > 0 {
|
||||
totalDiscs = maxDiscFromAlbum
|
||||
} else if discInfo["totalDiscs"] != nil {
|
||||
totalDiscs = discInfo["totalDiscs"].(int)
|
||||
}
|
||||
|
||||
contentRating := getMap(trackData, "contentRating")
|
||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||
|
||||
filtered := map[string]interface{}{
|
||||
"id": getString(trackData, "id"),
|
||||
"name": getString(trackData, "name"),
|
||||
"artists": artistsString,
|
||||
"album": albumInfo,
|
||||
"duration": durationString,
|
||||
"track": int(getFloat64(trackData, "trackNumber")),
|
||||
"disc": discNumber,
|
||||
"discs": totalDiscs,
|
||||
"copyright": copyrightString,
|
||||
"plays": getString(trackData, "playcount"),
|
||||
"cover": cover,
|
||||
"id": getString(trackData, "id"),
|
||||
"name": getString(trackData, "name"),
|
||||
"artists": artistsString,
|
||||
"album": albumInfo,
|
||||
"duration": durationString,
|
||||
"track": int(getFloat64(trackData, "trackNumber")),
|
||||
"disc": discNumber,
|
||||
"discs": totalDiscs,
|
||||
"copyright": copyrightString,
|
||||
"plays": getString(trackData, "playcount"),
|
||||
"cover": cover,
|
||||
"is_explicit": isExplicit,
|
||||
}
|
||||
|
||||
return filtered
|
||||
@@ -860,13 +924,23 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
trackID = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
contentRating := getMap(track, "contentRating")
|
||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||
|
||||
discNumber := int(getFloat64(track, "discNumber"))
|
||||
if discNumber == 0 {
|
||||
discNumber = 1
|
||||
}
|
||||
|
||||
trackInfo := map[string]interface{}{
|
||||
"id": trackID,
|
||||
"name": getString(track, "name"),
|
||||
"artists": trackArtistsString,
|
||||
"artistIds": artistIDs,
|
||||
"duration": durationString,
|
||||
"plays": getString(track, "playcount"),
|
||||
"id": trackID,
|
||||
"name": getString(track, "name"),
|
||||
"artists": trackArtistsString,
|
||||
"artistIds": artistIDs,
|
||||
"duration": durationString,
|
||||
"plays": getString(track, "playcount"),
|
||||
"is_explicit": isExplicit,
|
||||
"disc_number": discNumber,
|
||||
}
|
||||
tracks = append(tracks, trackInfo)
|
||||
}
|
||||
@@ -886,6 +960,12 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
albumID = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
totalDiscs := 1
|
||||
discsData := getMap(albumData, "discs")
|
||||
if len(discsData) > 0 {
|
||||
totalDiscs = int(getFloat64(discsData, "totalCount"))
|
||||
}
|
||||
|
||||
filtered := map[string]interface{}{
|
||||
"id": albumID,
|
||||
"name": getString(albumData, "name"),
|
||||
@@ -894,6 +974,9 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
"releaseDate": releaseDate,
|
||||
"count": len(tracks),
|
||||
"tracks": tracks,
|
||||
"discs": map[string]interface{}{
|
||||
"totalCount": totalDiscs,
|
||||
},
|
||||
}
|
||||
|
||||
return filtered
|
||||
@@ -1081,10 +1164,18 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
contentRating := getMap(trackData, "contentRating")
|
||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||
|
||||
trackName := getString(trackData, "name")
|
||||
if trackName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
trackInfo := map[string]interface{}{
|
||||
"id": trackID,
|
||||
"cover": trackCover,
|
||||
"title": getString(trackData, "name"),
|
||||
"title": trackName,
|
||||
"artist": artistsString,
|
||||
"artistIds": artistIDs,
|
||||
"plays": rank,
|
||||
@@ -1093,6 +1184,8 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
"albumArtist": albumArtistsString,
|
||||
"albumId": albumID,
|
||||
"duration": durationString,
|
||||
"is_explicit": isExplicit,
|
||||
"disc_number": int(getFloat64(trackData, "discNumber")),
|
||||
}
|
||||
tracks = append(tracks, trackInfo)
|
||||
}
|
||||
@@ -1186,12 +1279,20 @@ func extractRelease(release map[string]interface{}) map[string]interface{} {
|
||||
year = yearVal
|
||||
}
|
||||
|
||||
var totalTracks int
|
||||
tracksInfo := getMap(release, "tracks")
|
||||
if tracksInfo != nil {
|
||||
totalTracks = int(getFloat64(tracksInfo, "totalCount"))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": releaseID,
|
||||
"name": getString(release, "name"),
|
||||
"cover": cover,
|
||||
"date": releaseDate,
|
||||
"year": year,
|
||||
"id": releaseID,
|
||||
"name": getString(release, "name"),
|
||||
"cover": cover,
|
||||
"date": releaseDate,
|
||||
"year": year,
|
||||
"total_tracks": totalTracks,
|
||||
"type": getString(release, "type"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1228,6 +1329,11 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte
|
||||
return items
|
||||
}
|
||||
|
||||
func stripHTMLTags(s string) string {
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
return re.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
artistData := getMap(dataMap, "artistUnion")
|
||||
@@ -1243,7 +1349,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||
if ok {
|
||||
biographyText := getString(biographyMap, "text")
|
||||
if biographyText != "" {
|
||||
profile["biography"] = html.UnescapeString(biographyText)
|
||||
profile["biography"] = html.UnescapeString(stripHTMLTags(biographyText))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1456,14 +1562,18 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
albumName = getString(albumInfo, "name")
|
||||
}
|
||||
|
||||
contentRating := getMap(track, "contentRating")
|
||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||
|
||||
trackResults := results["tracks"].([]map[string]interface{})
|
||||
trackResults = append(trackResults, map[string]interface{}{
|
||||
"id": trackID,
|
||||
"name": trackName,
|
||||
"artists": trackArtistsString,
|
||||
"album": albumName,
|
||||
"duration": durationString,
|
||||
"cover": cover,
|
||||
"id": trackID,
|
||||
"name": trackName,
|
||||
"artists": trackArtistsString,
|
||||
"album": albumName,
|
||||
"duration": durationString,
|
||||
"cover": cover,
|
||||
"is_explicit": isExplicit,
|
||||
})
|
||||
results["tracks"] = trackResults
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
if !useAPI || apiBaseURL == "" {
|
||||
|
||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay)
|
||||
}
|
||||
|
||||
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
||||
if spotifyType == "" || id == "" {
|
||||
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create API request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read API response: %w", err)
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
|
||||
switch spotifyType {
|
||||
case "track":
|
||||
var trackResp TrackResponse
|
||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||
}
|
||||
data = trackResp
|
||||
case "album":
|
||||
var albumResp AlbumResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||
}
|
||||
data = &albumResp
|
||||
case "playlist":
|
||||
var playlistResp PlaylistResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||
}
|
||||
data = playlistResp
|
||||
case "artist":
|
||||
var artistResp ArtistDiscographyPayload
|
||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||
}
|
||||
data = &artistResp
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func parseSpotifyURLToTypeAndID(url string) (string, string) {
|
||||
|
||||
if strings.HasPrefix(url, "spotify:") {
|
||||
parts := strings.Split(url, ":")
|
||||
if len(parts) >= 3 {
|
||||
return parts[1], parts[2]
|
||||
}
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`spotify\.com/(track|album|playlist|artist)/([a-zA-Z0-9]+)`)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) == 3 {
|
||||
return matches[1], matches[2]
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
@@ -47,6 +47,7 @@ type TrackMetadata struct {
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
Plays string `json:"plays,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistSimple struct {
|
||||
@@ -79,6 +80,7 @@ type AlbumTrackMetadata struct {
|
||||
Plays string `json:"plays,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
@@ -198,6 +200,7 @@ type apiTrackResponse struct {
|
||||
Medium string `json:"medium"`
|
||||
Large string `json:"large"`
|
||||
} `json:"cover"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
}
|
||||
|
||||
type apiAlbumResponse struct {
|
||||
@@ -207,13 +210,18 @@ type apiAlbumResponse struct {
|
||||
Cover string `json:"cover"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Count int `json:"count"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Duration string `json:"duration"`
|
||||
Plays string `json:"plays"`
|
||||
Discs struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"discs"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Duration string `json:"duration"`
|
||||
Plays string `json:"plays"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
@@ -240,6 +248,8 @@ type apiPlaylistResponse struct {
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId"`
|
||||
Duration string `json:"duration"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
@@ -261,11 +271,13 @@ type apiArtistResponse struct {
|
||||
Gallery []string `json:"gallery"`
|
||||
Discography struct {
|
||||
All []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Cover string `json:"cover"`
|
||||
Date string `json:"date"`
|
||||
Year int `json:"year"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Cover string `json:"cover"`
|
||||
Date string `json:"date"`
|
||||
Year int `json:"year"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Type string `json:"type"`
|
||||
} `json:"all"`
|
||||
Total int `json:"total"`
|
||||
} `json:"discography"`
|
||||
@@ -274,12 +286,13 @@ type apiArtistResponse struct {
|
||||
type apiSearchResponse struct {
|
||||
Results struct {
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
Duration string `json:"duration"`
|
||||
Cover string `json:"cover"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
Duration string `json:"duration"`
|
||||
Cover string `json:"cover"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
} `json:"tracks"`
|
||||
Albums []struct {
|
||||
ID string `json:"id"`
|
||||
@@ -320,6 +333,7 @@ type SearchResult struct {
|
||||
Duration int `json:"duration_ms,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
@@ -423,22 +437,45 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
||||
}
|
||||
|
||||
if albumID != "" {
|
||||
albumPayload := map[string]interface{}{
|
||||
"variables": map[string]interface{}{
|
||||
"uri": fmt.Sprintf("spotify:album:%s", albumID),
|
||||
"locale": "",
|
||||
"offset": 0,
|
||||
"limit": 1,
|
||||
},
|
||||
"operationName": "getAlbum",
|
||||
"extensions": map[string]interface{}{
|
||||
"persistedQuery": map[string]interface{}{
|
||||
"version": 1,
|
||||
"sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
|
||||
|
||||
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
|
||||
if err == nil && albumResponse != nil {
|
||||
|
||||
albumJSON, _ := json.Marshal(albumResponse)
|
||||
var albumMap map[string]interface{}
|
||||
json.Unmarshal(albumJSON, &albumMap)
|
||||
|
||||
tracksItems := []interface{}{}
|
||||
if albumMap["tracks"] != nil {
|
||||
if trackList, ok := albumMap["tracks"].([]interface{}); ok {
|
||||
for _, t := range trackList {
|
||||
if trackMap, ok := t.(map[string]interface{}); ok {
|
||||
tracksItems = append(tracksItems, map[string]interface{}{
|
||||
"track": map[string]interface{}{
|
||||
"discNumber": trackMap["disc_number"],
|
||||
"id": trackMap["id"],
|
||||
"uri": fmt.Sprintf("spotify:track:%s", trackMap["id"]),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
albumFetchData = map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"albumUnion": map[string]interface{}{
|
||||
"discs": map[string]interface{}{
|
||||
"totalCount": albumResponse.Discs.TotalCount,
|
||||
},
|
||||
"tracks": map[string]interface{}{
|
||||
"items": tracksItems,
|
||||
"totalCount": albumResponse.Count,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
albumFetchData, _ = client.Query(albumPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,6 +501,10 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string)
|
||||
if err := client.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||
}
|
||||
return c.fetchAlbumWithClient(ctx, client, albumID)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) {
|
||||
|
||||
allItems := []interface{}{}
|
||||
offset := 0
|
||||
@@ -727,6 +768,12 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
||||
}
|
||||
|
||||
offset += limit
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
albumsItems := []interface{}{}
|
||||
@@ -843,6 +890,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
||||
Copyright: raw.Copyright,
|
||||
Publisher: raw.Album.Label,
|
||||
Plays: raw.Plays,
|
||||
IsExplicit: raw.IsExplicit,
|
||||
}
|
||||
|
||||
return TrackResponse{
|
||||
@@ -894,8 +942,8 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
||||
ReleaseDate: raw.ReleaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: raw.Count,
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 0,
|
||||
DiscNumber: item.DiscNumber,
|
||||
TotalDiscs: raw.Discs.TotalCount,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
ISRC: item.ID,
|
||||
AlbumID: raw.ID,
|
||||
@@ -904,6 +952,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
Plays: item.Plays,
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -953,7 +1002,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
ReleaseDate: "",
|
||||
TrackNumber: 0,
|
||||
TotalTracks: 0,
|
||||
DiscNumber: 1,
|
||||
DiscNumber: item.DiscNumber,
|
||||
TotalDiscs: 0,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
ISRC: item.ID,
|
||||
@@ -964,6 +1013,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
ArtistsData: artistsData,
|
||||
Plays: item.Plays,
|
||||
Status: item.Status,
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -995,79 +1045,105 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All))
|
||||
allTracks := make([]AlbumTrackMetadata, 0)
|
||||
|
||||
type fetchResult struct {
|
||||
tracks []AlbumTrackMetadata
|
||||
err error
|
||||
}
|
||||
|
||||
resultsChan := make(chan fetchResult, len(raw.Discography.All))
|
||||
sem := make(chan struct{}, 5)
|
||||
|
||||
sharedClient := NewSpotifyClient()
|
||||
if err := sharedClient.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize shared spotify client: %w", err)
|
||||
}
|
||||
|
||||
for _, alb := range raw.Discography.All {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
return &ArtistDiscographyPayload{
|
||||
ArtistInfo: info,
|
||||
AlbumList: albumList,
|
||||
TrackList: allTracks,
|
||||
}, ctx.Err()
|
||||
default:
|
||||
|
||||
}
|
||||
|
||||
albumList = append(albumList, DiscographyAlbumMetadata{
|
||||
ID: alb.ID,
|
||||
Name: alb.Name,
|
||||
AlbumType: "album",
|
||||
AlbumType: alb.Type,
|
||||
ReleaseDate: alb.Date,
|
||||
TotalTracks: 0,
|
||||
TotalTracks: alb.TotalTracks,
|
||||
Artists: raw.Name,
|
||||
Images: alb.Cover,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||
})
|
||||
|
||||
albumData, err := c.fetchAlbum(ctx, alb.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err)
|
||||
continue
|
||||
}
|
||||
go func(albumID string, albumName string) {
|
||||
sem <- struct{}{}
|
||||
|
||||
for idx, tr := range albumData.Tracks {
|
||||
durationMS := parseDuration(tr.Duration)
|
||||
trackNumber := idx + 1
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
defer func() { <-sem }()
|
||||
|
||||
var artistID, artistURL string
|
||||
if len(tr.ArtistIds) > 0 {
|
||||
artistID = tr.ArtistIds[0]
|
||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
resultsChan <- fetchResult{err: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds))
|
||||
for _, id := range tr.ArtistIds {
|
||||
artistsData = append(artistsData, ArtistSimple{
|
||||
ID: id,
|
||||
Name: "",
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id),
|
||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
||||
return
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
|
||||
for idx, tr := range albumData.Tracks {
|
||||
durationMS := parseDuration(tr.Duration)
|
||||
trackNumber := idx + 1
|
||||
|
||||
var artistID, artistURL string
|
||||
if len(tr.ArtistIds) > 0 {
|
||||
artistID = tr.ArtistIds[0]
|
||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID)
|
||||
}
|
||||
|
||||
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds))
|
||||
for _, id := range tr.ArtistIds {
|
||||
artistsData = append(artistsData, ArtistSimple{
|
||||
ID: id,
|
||||
Name: "",
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id),
|
||||
})
|
||||
}
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: tr.ID,
|
||||
Artists: tr.Artists,
|
||||
Name: tr.Name,
|
||||
AlbumName: albumData.Name,
|
||||
AlbumArtist: raw.Name,
|
||||
AlbumType: "album",
|
||||
DurationMS: durationMS,
|
||||
Images: albumData.Cover,
|
||||
ReleaseDate: albumData.ReleaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: albumData.Count,
|
||||
DiscNumber: tr.DiscNumber,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
||||
ISRC: tr.ID,
|
||||
AlbumID: albumID,
|
||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID),
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
Plays: tr.Plays,
|
||||
IsExplicit: tr.IsExplicit,
|
||||
})
|
||||
}
|
||||
resultsChan <- fetchResult{tracks: tracks}
|
||||
}(alb.ID, alb.Name)
|
||||
}
|
||||
|
||||
allTracks = append(allTracks, AlbumTrackMetadata{
|
||||
SpotifyID: tr.ID,
|
||||
Artists: tr.Artists,
|
||||
Name: tr.Name,
|
||||
AlbumName: albumData.Name,
|
||||
AlbumArtist: albumData.Artists,
|
||||
AlbumType: "album",
|
||||
DurationMS: durationMS,
|
||||
Images: albumData.Cover,
|
||||
ReleaseDate: albumData.ReleaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: albumData.Count,
|
||||
DiscNumber: 1,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
||||
ISRC: tr.ID,
|
||||
AlbumID: alb.ID,
|
||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
Plays: tr.Plays,
|
||||
})
|
||||
for i := 0; i < len(raw.Discography.All); i++ {
|
||||
res := <-resultsChan
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
allTracks = append(allTracks, res.tracks...)
|
||||
}
|
||||
|
||||
return &ArtistDiscographyPayload{
|
||||
@@ -1246,6 +1322,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
||||
Images: item.Cover,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
Duration: parseDuration(item.Duration),
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1359,6 +1436,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
||||
Images: item.Cover,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
Duration: parseDuration(item.Duration),
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
case "album":
|
||||
|
||||
@@ -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"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -17,38 +18,10 @@ import (
|
||||
)
|
||||
|
||||
type TidalDownloader struct {
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
clientID string
|
||||
clientSecret string
|
||||
apiURL string
|
||||
}
|
||||
|
||||
type TidalTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ISRC string `json:"isrc"`
|
||||
AudioQuality string `json:"audioQuality"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
VolumeNumber int `json:"volumeNumber"`
|
||||
Duration int `json:"duration"`
|
||||
Copyright string `json:"copyright"`
|
||||
Explicit bool `json:"explicit"`
|
||||
Album struct {
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
} `json:"album"`
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
Artist struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artist"`
|
||||
MediaMetadata struct {
|
||||
Tags []string `json:"tags"`
|
||||
} `json:"mediaMetadata"`
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
apiURL string
|
||||
}
|
||||
|
||||
type TidalAPIResponse struct {
|
||||
@@ -70,11 +43,6 @@ type TidalAPIResponseV2 struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type TidalAPIInfo struct {
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TidalBTSManifest struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Codecs string `json:"codecs"`
|
||||
@@ -83,19 +51,14 @@ type TidalBTSManifest struct {
|
||||
}
|
||||
|
||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
||||
|
||||
if apiURL == "" {
|
||||
downloader := &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
clientID: string(clientID),
|
||||
clientSecret: string(clientSecret),
|
||||
apiURL: "",
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
apiURL: "",
|
||||
}
|
||||
|
||||
apis, err := downloader.GetAvailableAPIs()
|
||||
@@ -108,85 +71,38 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
clientID: string(clientID),
|
||||
clientSecret: string(clientSecret),
|
||||
apiURL: apiURL,
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
apiURL: apiURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
|
||||
encodedAPIs := []string{
|
||||
"dm9nZWwucXFkbC5zaXRl",
|
||||
"bWF1cy5xcWRsLnNpdGU=",
|
||||
"aHVuZC5xcWRsLnNpdGU=",
|
||||
"a2F0emUucXFkbC5zaXRl",
|
||||
"d29sZi5xcWRsLnNpdGU=",
|
||||
"dGlkYWwua2lub3BsdXMub25saW5l",
|
||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
|
||||
"dHJpdG9uLnNxdWlkLnd0Zg==",
|
||||
apis := []string{
|
||||
"https://triton.squid.wtf",
|
||||
"https://hifi-one.spotisaver.net",
|
||||
"https://hifi-two.spotisaver.net",
|
||||
"https://tidal.kinoplus.online",
|
||||
"https://tidal-api.binimum.org",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
|
||||
|
||||
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
||||
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(t.clientID, t.clientSecret)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
spotifyBase := "https://open.spotify.com/track/"
|
||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
||||
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", 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")
|
||||
|
||||
fmt.Println("Getting Tidal URL...")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
@@ -237,49 +153,21 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
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) {
|
||||
fmt.Println("Fetching URL...")
|
||||
|
||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||
fmt.Printf("Tidal API URL: %s\n", url)
|
||||
|
||||
resp, err := t.client.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ failed to create request: %v\n", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", 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")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
@@ -330,32 +218,20 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
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 {
|
||||
|
||||
if strings.HasPrefix(url, "MANIFEST:") {
|
||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
||||
}
|
||||
|
||||
resp, err := t.client.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", 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")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
@@ -385,7 +261,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
@@ -394,10 +270,19 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
if directURL != "" {
|
||||
doRequest := func(url string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, 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")
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
if directURL != "" && (strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "") {
|
||||
fmt.Println("Downloading file...")
|
||||
|
||||
resp, err := client.Get(directURL)
|
||||
resp, err := doRequest(directURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
@@ -424,83 +309,116 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
|
||||
|
||||
tempPath := outputPath + ".m4a.tmp"
|
||||
out, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("Downloading init segment... ")
|
||||
resp, err := client.Get(initURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to download init segment: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write init segment: %w", err)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
if directURL != "" {
|
||||
fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType)
|
||||
|
||||
totalSegments := len(mediaURLs)
|
||||
var totalBytes int64
|
||||
lastTime := time.Now()
|
||||
var lastBytes int64
|
||||
for i, mediaURL := range mediaURLs {
|
||||
resp, err := client.Get(mediaURL)
|
||||
resp, err := doRequest(directURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
|
||||
} else {
|
||||
|
||||
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
|
||||
|
||||
out, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print("Downloading init segment... ")
|
||||
resp, err := doRequest(initURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||
return fmt.Errorf("failed to download init segment: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
n, err := io.Copy(out, resp.Body)
|
||||
totalBytes += n
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||
return fmt.Errorf("failed to write init segment: %w", err)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
|
||||
totalSegments := len(mediaURLs)
|
||||
var totalBytes int64
|
||||
lastTime := time.Now()
|
||||
var lastBytes int64
|
||||
for i, mediaURL := range mediaURLs {
|
||||
resp, err := doRequest(mediaURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
}
|
||||
n, err := io.Copy(out, resp.Body)
|
||||
totalBytes += n
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||
}
|
||||
|
||||
mbDownloaded := float64(totalBytes) / (1024 * 1024)
|
||||
now := time.Now()
|
||||
timeDiff := now.Sub(lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
if timeDiff > 0.1 {
|
||||
bytesDiff := float64(totalBytes - lastBytes)
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
SetDownloadSpeed(speedMBps)
|
||||
lastTime = now
|
||||
lastBytes = totalBytes
|
||||
}
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
|
||||
fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
|
||||
}
|
||||
|
||||
mbDownloaded := float64(totalBytes) / (1024 * 1024)
|
||||
now := time.Now()
|
||||
timeDiff := now.Sub(lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
if timeDiff > 0.1 {
|
||||
bytesDiff := float64(totalBytes - lastBytes)
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
SetDownloadSpeed(speedMBps)
|
||||
lastTime = now
|
||||
lastBytes = totalBytes
|
||||
}
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
out.Close()
|
||||
|
||||
fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
|
||||
tempInfo, _ := os.Stat(tempPath)
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
|
||||
}
|
||||
|
||||
out.Close()
|
||||
|
||||
tempInfo, _ := os.Stat(tempPath)
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
|
||||
|
||||
fmt.Println("Converting to FLAC...")
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
@@ -528,7 +446,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
||||
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 err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("directory error: %w", err)
|
||||
@@ -542,12 +460,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
return "", err
|
||||
}
|
||||
|
||||
trackInfo, err := t.GetTrackInfoByID(trackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if trackInfo.ID == 0 {
|
||||
if trackID == 0 {
|
||||
return "", fmt.Errorf("no track ID found")
|
||||
}
|
||||
|
||||
@@ -560,7 +473,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||
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)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -568,9 +481,17 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
return "EXISTS:" + outputFilename, nil
|
||||
}
|
||||
|
||||
downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality)
|
||||
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
||||
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)
|
||||
@@ -626,7 +547,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
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()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
@@ -645,12 +566,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return "", err
|
||||
}
|
||||
|
||||
trackInfo, err := t.GetTrackInfoByID(trackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if trackInfo.ID == 0 {
|
||||
if trackID == 0 {
|
||||
return "", fmt.Errorf("no track ID found")
|
||||
}
|
||||
|
||||
@@ -663,7 +579,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||
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)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -671,9 +587,17 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return "EXISTS:" + outputFilename, nil
|
||||
}
|
||||
|
||||
successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality)
|
||||
successAPI, downloadURL, err := getDownloadURLRotated(apis, trackID, quality)
|
||||
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)
|
||||
@@ -730,14 +654,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
@@ -768,10 +692,10 @@ type MPD struct {
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, mimeType string, err error) {
|
||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
||||
return "", "", nil, "", fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
manifestStr := string(manifestBytes)
|
||||
@@ -779,15 +703,15 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
|
||||
var btsManifest TidalBTSManifest
|
||||
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)
|
||||
}
|
||||
|
||||
if len(btsManifest.URLs) == 0 {
|
||||
return "", "", nil, fmt.Errorf("no URLs in BTS manifest")
|
||||
return "", "", nil, "", fmt.Errorf("no URLs in BTS manifest")
|
||||
}
|
||||
|
||||
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
|
||||
return btsManifest.URLs[0], "", nil, nil
|
||||
return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil
|
||||
}
|
||||
|
||||
fmt.Println("Manifest: DASH format")
|
||||
@@ -852,7 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
}
|
||||
return "", initURL, mediaURLs, nil
|
||||
return "", initURL, mediaURLs, "", nil
|
||||
}
|
||||
|
||||
fmt.Println("Using regex fallback for DASH manifest...")
|
||||
@@ -868,7 +792,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
}
|
||||
|
||||
if initURL == "" {
|
||||
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
||||
return "", "", nil, "", fmt.Errorf("no initialization URL found in manifest")
|
||||
}
|
||||
|
||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||
@@ -889,7 +813,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
}
|
||||
|
||||
if segmentCount == 0 {
|
||||
return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
||||
return "", "", nil, "", fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
||||
}
|
||||
|
||||
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
||||
@@ -899,92 +823,70 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
}
|
||||
|
||||
return "", initURL, mediaURLs, nil
|
||||
return "", initURL, mediaURLs, "", nil
|
||||
}
|
||||
|
||||
type manifestResult struct {
|
||||
apiURL string
|
||||
manifest string
|
||||
err error
|
||||
}
|
||||
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
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))
|
||||
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)
|
||||
}
|
||||
fmt.Printf("Rotating through %d APIs...\n", len(apis))
|
||||
|
||||
var lastError error
|
||||
var errors []string
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil && result.manifest != "" {
|
||||
for _, apiURL := range apis {
|
||||
fmt.Printf("Trying API: %s\n", apiURL)
|
||||
|
||||
fmt.Printf("✓ Got response from: %s\n", result.apiURL)
|
||||
|
||||
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
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
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:")
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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()
|
||||
|
||||
uploadURL, err := getUploadURL()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get upload server: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", uploadURL, 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 getUploadURL() (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://send.now/", 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()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("failed to fetch main page: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := string(bodyBytes)
|
||||
|
||||
re := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi\?upload_type=file[^"']*)["']`)
|
||||
matches := re.FindStringSubmatch(body)
|
||||
if len(matches) > 1 {
|
||||
return matches[1], nil
|
||||
}
|
||||
|
||||
reFallback := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi)`)
|
||||
matchesFallback := reFallback.FindStringSubmatch(body)
|
||||
if len(matchesFallback) > 1 {
|
||||
return matchesFallback[1] + "?upload_type=file&utype=anon", nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("upload URL not found in main page")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -16,10 +16,13 @@
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@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-slot": "^1.2.4",
|
||||
"@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-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
@@ -51,4 +54,4 @@
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
68754ba75ba7fe058dd9ebf6593e2759
|
||||
9fee02ec6592ede9ade4b36d56bd4d6d
|
||||
@@ -20,9 +20,15 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-menubar':
|
||||
specifier: ^1.1.16
|
||||
version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -32,6 +38,9 @@ importers:
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.2.6
|
||||
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.10
|
||||
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -461,89 +470,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -761,6 +786,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-menubar@1.1.16':
|
||||
resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -852,6 +890,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10':
|
||||
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.6':
|
||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||
peerDependencies:
|
||||
@@ -896,6 +947,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.11':
|
||||
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
||||
peerDependencies:
|
||||
@@ -1060,66 +1124,79 @@ packages:
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
@@ -1189,24 +1266,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
@@ -1691,24 +1772,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
@@ -2626,6 +2711,24 @@ snapshots:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -2709,6 +2812,23 @@ snapshots:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
@@ -2767,6 +2887,22 @@ snapshots:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||
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 { TitleBar } from "@/components/TitleBar";
|
||||
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||
@@ -24,6 +23,8 @@ import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import { AboutPage } from "@/components/AboutPage";
|
||||
import { HistoryPage } from "@/components/HistoryPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
import { useDownload } from "@/hooks/useDownload";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
@@ -31,6 +32,7 @@ import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||
const MAX_HISTORY = 5;
|
||||
function App() {
|
||||
@@ -44,19 +46,28 @@ function App() {
|
||||
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
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 [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
|
||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
||||
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "7.0.5";
|
||||
const download = useDownload();
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
const download = useDownload(region);
|
||||
const metadata = useMetadata();
|
||||
const lyrics = useLyrics();
|
||||
const cover = useCover();
|
||||
const availability = useAvailability();
|
||||
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) {
|
||||
@@ -65,7 +76,6 @@ function App() {
|
||||
applyFont(savedSettings.fontFamily);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initSettings = async () => {
|
||||
const settings = await loadSettings();
|
||||
@@ -78,6 +88,17 @@ function App() {
|
||||
}
|
||||
};
|
||||
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 handleChange = () => {
|
||||
const currentSettings = getSettings();
|
||||
@@ -138,6 +159,44 @@ function App() {
|
||||
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[]) => {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
@@ -236,11 +295,14 @@ function App() {
|
||||
};
|
||||
const toggleSelectAll = (tracks: any[]) => {
|
||||
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
|
||||
if (selectedTracks.length === tracksWithIsrc.length) {
|
||||
setSelectedTracks([]);
|
||||
if (tracksWithIsrc.length === 0)
|
||||
return;
|
||||
const allSelected = tracksWithIsrc.every(isrc => selectedTracks.includes(isrc));
|
||||
if (allSelected) {
|
||||
setSelectedTracks(prev => prev.filter(isrc => !tracksWithIsrc.includes(isrc)));
|
||||
}
|
||||
else {
|
||||
setSelectedTracks(tracksWithIsrc);
|
||||
setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithIsrc])));
|
||||
}
|
||||
};
|
||||
const handleOpenFolder = async () => {
|
||||
@@ -262,49 +324,49 @@ function App() {
|
||||
return null;
|
||||
if ("track" in metadata.metadata) {
|
||||
const { track } = metadata.metadata;
|
||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} />);
|
||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
||||
}
|
||||
if ("album_info" in metadata.metadata) {
|
||||
const { album_info, track_list } = metadata.metadata;
|
||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}} />);
|
||||
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);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
}
|
||||
if ("playlist_info" in metadata.metadata) {
|
||||
const { playlist_info, track_list } = metadata.metadata;
|
||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}} />);
|
||||
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);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
}
|
||||
if ("artist_info" in 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) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}} />);
|
||||
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);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -337,9 +399,16 @@ function App() {
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "settings":
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />;
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "about":
|
||||
return <AboutPage version={CURRENT_VERSION}/>;
|
||||
case "history":
|
||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||
metadata.loadFromCache(cachedData);
|
||||
setCurrentPage("main");
|
||||
}}/>;
|
||||
case "audio-analysis":
|
||||
return <AudioAnalysisPage />;
|
||||
case "audio-converter":
|
||||
@@ -348,52 +417,16 @@ function App() {
|
||||
return <FileManagerPage />;
|
||||
default:
|
||||
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">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
@@ -408,12 +441,12 @@ function App() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={async () => {
|
||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||
if (albumUrl) {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4" />
|
||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||
if (albumUrl) {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4"/>
|
||||
Fetch Album
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -426,7 +459,7 @@ function App() {
|
||||
if (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()}
|
||||
</>);
|
||||
@@ -435,7 +468,7 @@ function App() {
|
||||
return (<TooltipProvider>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||
|
||||
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
@@ -445,14 +478,14 @@ function App() {
|
||||
</div>
|
||||
|
||||
|
||||
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
||||
|
||||
|
||||
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue} />
|
||||
<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" />
|
||||
<ArrowUp className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
|
||||
|
||||
@@ -474,6 +507,54 @@ function App() {
|
||||
</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>
|
||||
</TooltipProvider>);
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.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 |
@@ -0,0 +1,13 @@
|
||||
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1_219)">
|
||||
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,466 @@
|
||||
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, Heart } 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 BmcLogo from "@/assets/bmc-logo.svg";
|
||||
import KofiLogo from "@/assets/kofi_symbol.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" | "support">("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>
|
||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
||||
<Heart className="h-4 w-4"/>
|
||||
Support Us
|
||||
</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>)}
|
||||
|
||||
|
||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-2xl font-bold tracking-tight">Support Our Work</h3>
|
||||
<p className="text-muted-foreground max-w-[500px]">
|
||||
If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4 w-full max-w-lg">
|
||||
<Button size="lg" className="h-16 text-lg font-semibold text-white gap-3 group" style={{ backgroundColor: "#72a4f2" }} onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
|
||||
Support me on Ko-fi
|
||||
</Button>
|
||||
|
||||
<Button size="lg" className="h-16 text-lg font-semibold text-black gap-3 group" style={{ backgroundColor: "#ffdd00" }} onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")}>
|
||||
<img src={BmcLogo} className="h-6 w-6 transition-transform group-hover:scale-110" alt="Buy Me a Coffee"/>
|
||||
Buy Me a Coffee
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
@@ -67,10 +67,16 @@ interface AlbumInfoProps {
|
||||
external_urls: string;
|
||||
}) => 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">
|
||||
<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">
|
||||
<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"/>)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
@@ -10,7 +10,10 @@ import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
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 {
|
||||
artistInfo: {
|
||||
name: string;
|
||||
@@ -31,6 +34,7 @@ interface ArtistInfoProps {
|
||||
release_date: string;
|
||||
album_type: string;
|
||||
external_urls: string;
|
||||
total_tracks?: number;
|
||||
}>;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
@@ -87,12 +91,40 @@ interface ArtistInfoProps {
|
||||
}) => void;
|
||||
onPageChange: (page: number) => 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 [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||
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 () => {
|
||||
if (!artistInfo.header)
|
||||
return;
|
||||
@@ -238,13 +270,19 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
setDownloadingAllGallery(false);
|
||||
}
|
||||
};
|
||||
const hasGallery = artistInfo.gallery && artistInfo.gallery.length > 0;
|
||||
return (<div className="space-y-6">
|
||||
<Card className="overflow-hidden p-0">
|
||||
<Card className="overflow-hidden p-0 relative">
|
||||
{artistInfo.header ? (<>
|
||||
<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-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>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{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">
|
||||
<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 && (<>
|
||||
<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>
|
||||
</>)}
|
||||
<span>•</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
@@ -304,6 +343,11 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</div>
|
||||
</div>
|
||||
</>) : (<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">
|
||||
{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"/>
|
||||
@@ -324,23 +368,24 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
<p className="text-sm font-medium">Artist</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
||||
<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 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
||||
</>)}
|
||||
{artistInfo.rank && (<>
|
||||
<span>•</span>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</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>{albumList.length} albums</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} tracks</span>
|
||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
{artistInfo.genres.length > 0 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
@@ -351,9 +396,23 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</CardContent>)}
|
||||
</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">
|
||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3>
|
||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length})</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||
@@ -366,7 +425,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
@@ -386,29 +445,76 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</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>
|
||||
<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({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
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"/>)}
|
||||
<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>
|
||||
<h4 className="font-semibold truncate">{album.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{album.release_date?.split("-")[0]}
|
||||
</p>
|
||||
</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">
|
||||
<h3 className="text-2xl font-bold">All Tracks</h3>
|
||||
<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}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download All
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
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 { 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 { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
interface AudioFile {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -38,9 +36,6 @@ const M4A_CODEC_OPTIONS = [
|
||||
];
|
||||
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
||||
export function AudioConverterPage() {
|
||||
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
|
||||
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
|
||||
const downloadProgress = useDownloadProgress();
|
||||
const [files, setFiles] = useState<AudioFile[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
@@ -114,9 +109,6 @@ export function AudioConverterPage() {
|
||||
console.error("Failed to save state:", err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
checkFfmpegInstallation();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
saveState({ files, outputFormat, bitrate, m4aCodec });
|
||||
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
||||
@@ -147,41 +139,6 @@ export function AudioConverterPage() {
|
||||
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 () => {
|
||||
try {
|
||||
const selectedFiles = await SelectAudioFiles();
|
||||
@@ -250,15 +207,13 @@ export function AudioConverterPage() {
|
||||
addFiles(paths);
|
||||
}, [addFiles]);
|
||||
useEffect(() => {
|
||||
if (ffmpegInstalled === true) {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}
|
||||
}, [handleFileDrop, ffmpegInstalled]);
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
const removeFile = (path: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||
};
|
||||
@@ -288,7 +243,7 @@ export function AudioConverterPage() {
|
||||
codec: outputFormat === "m4a" ? m4aCodec : "",
|
||||
});
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
const result = results.find((r) => r.input_file === f.path);
|
||||
const result = results.find((r) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
|
||||
if (result) {
|
||||
return {
|
||||
...f,
|
||||
@@ -336,62 +291,24 @@ export function AudioConverterPage() {
|
||||
};
|
||||
const convertableCount = files.filter((f) => f.status === "pending" || 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" : ""}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||
</div>
|
||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
|
||||
<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="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Download className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
FFmpeg is required to convert audio files
|
||||
</p>
|
||||
<Button onClick={handleInstallFfmpeg} disabled={installingFfmpeg} size="lg">
|
||||
{installingFfmpeg ? (<>
|
||||
<Spinner className="h-5 w-5"/>
|
||||
Installing FFmpeg...
|
||||
</>) : (<>
|
||||
<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 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>);
|
||||
}
|
||||
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-muted-foreground/30"}`} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -403,110 +320,110 @@ export function AudioConverterPage() {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||
{files.length === 0 ? (<>
|
||||
<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"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
{files.length === 0 ? (<>
|
||||
<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"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
</>) : (<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="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Format:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
||||
</p>
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
</>) : (<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="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Format:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
||||
if (value && !isFormatDisabled)
|
||||
setOutputFormat(value as "mp3" | "m4a");
|
||||
}} disabled={isFormatDisabled}>
|
||||
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||
MP3
|
||||
</ToggleGroupItem>)}
|
||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||
M4A
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Codec:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
|
||||
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||
MP3
|
||||
</ToggleGroupItem>)}
|
||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||
M4A
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Codec:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
|
||||
if (value)
|
||||
setM4aCodec(value as "aac" | "alac");
|
||||
}}>
|
||||
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
|
||||
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
||||
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
|
||||
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
||||
if (value)
|
||||
setBitrate(value);
|
||||
}}>
|
||||
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{files.length} file(s) • {successCount} converted
|
||||
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{files.length} file(s) • {successCount} converted
|
||||
</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 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 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 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>);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Trash2, Copy, Check } from "lucide-react";
|
||||
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logger, type LogEntry } from "@/lib/logger";
|
||||
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
const levelColors: Record<string, string> = {
|
||||
info: "text-blue-500",
|
||||
success: "text-green-500",
|
||||
@@ -51,10 +53,29 @@ export function DebugLoggerPage() {
|
||||
console.error("Failed to copy logs:", err);
|
||||
}
|
||||
};
|
||||
const handleExportFailed = async () => {
|
||||
try {
|
||||
const message = await ExportFailedDownloads();
|
||||
if (message.startsWith("Successfully")) {
|
||||
toast.success(message);
|
||||
}
|
||||
else if (message !== "Export cancelled") {
|
||||
toast.info(message);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to export:", error);
|
||||
toast.error(`Failed to export: ${error}`);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed}>
|
||||
<FileDown className="h-4 w-4"/>
|
||||
Export Failed
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
|
||||
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||
Copy
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads } from "../../wailsjs/go/main/App";
|
||||
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads, ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
interface DownloadQueueProps {
|
||||
@@ -59,6 +59,21 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
console.error("Failed to reset queue:", error);
|
||||
}
|
||||
};
|
||||
const handleExportFailed = async () => {
|
||||
try {
|
||||
const message = await ExportFailedDownloads();
|
||||
if (message.startsWith("Successfully")) {
|
||||
toast.success(message);
|
||||
}
|
||||
else if (message !== "Export cancelled") {
|
||||
toast.info(message);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to export:", error);
|
||||
toast.error(`Failed to export: ${error}`);
|
||||
}
|
||||
};
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "downloading":
|
||||
@@ -105,6 +120,15 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const toggleFilter = (status: string) => {
|
||||
setFilterStatus(prev => prev === status ? "all" : status);
|
||||
};
|
||||
const filteredQueue = queueInfo.queue.filter((item: any) => {
|
||||
if (filterStatus === "all")
|
||||
return true;
|
||||
return item.status === filterStatus;
|
||||
});
|
||||
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">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||
@@ -115,6 +139,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
Clear History
|
||||
</Button>)}
|
||||
{queueInfo.failed_count > 0 && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleExportFailed}>
|
||||
<FileDown className="h-3 w-3"/>
|
||||
Export Failures
|
||||
</Button>)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
@@ -123,22 +151,22 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
|
||||
|
||||
<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 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'queued' ? 'bg-secondary px-2 py-0.5 rounded-md ring-1 ring-border' : ''}`} onClick={() => toggleFilter('queued')}>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Queued:</span>
|
||||
<span className="font-semibold">{queueInfo.queued_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'completed' ? 'bg-green-500/10 px-2 py-0.5 rounded-md ring-1 ring-green-500/20' : ''}`} onClick={() => toggleFilter('completed')}>
|
||||
<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">
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'skipped' ? 'bg-yellow-500/10 px-2 py-0.5 rounded-md ring-1 ring-yellow-500/20' : ''}`} onClick={() => toggleFilter('skipped')}>
|
||||
<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">
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'failed' ? 'bg-red-500/10 px-2 py-0.5 rounded-md ring-1 ring-red-500/20' : ''}`} onClick={() => toggleFilter('failed')}>
|
||||
<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>
|
||||
@@ -180,7 +208,10 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
{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>) : filteredQueue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No downloads with status "{filterStatus}"</p>
|
||||
<Button variant="link" onClick={() => setFilterStatus("all")}>Clear filter</Button>
|
||||
</div>) : (filteredQueue.map((item: any) => (<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>
|
||||
|
||||
|
||||
@@ -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 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 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 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);
|
||||
@@ -118,8 +112,6 @@ export function FileManagerPage() {
|
||||
const [metadataFile, setMetadataFile] = useState<string>("");
|
||||
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
|
||||
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
||||
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
|
||||
const [installingFFprobe, setInstallingFFprobe] = useState(false);
|
||||
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
|
||||
const [lyricsContent, setLyricsContent] = useState("");
|
||||
const [lyricsFile, setLyricsFile] = useState("");
|
||||
@@ -279,14 +271,6 @@ export function FileManagerPage() {
|
||||
toast.error("No files selected");
|
||||
return;
|
||||
}
|
||||
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
|
||||
if (hasM4A) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
||||
setPreviewData(result);
|
||||
@@ -299,13 +283,6 @@ export function FileManagerPage() {
|
||||
};
|
||||
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (filePath.toLowerCase().endsWith(".m4a")) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMetadataFile(filePath);
|
||||
setLoadingMetadata(true);
|
||||
try {
|
||||
@@ -321,24 +298,6 @@ export function FileManagerPage() {
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
setLyricsFile(filePath);
|
||||
@@ -389,17 +348,17 @@ export function FileManagerPage() {
|
||||
if (!text)
|
||||
return null;
|
||||
return (<div key={index} className="flex items-center gap-2 py-1">
|
||||
<Badge variant="secondary" className="font-mono text-xs shrink-0">
|
||||
{formatTimestamp(timestamp)}
|
||||
</Badge>
|
||||
<span className="text-sm">{text}</span>
|
||||
</div>);
|
||||
<Badge variant="secondary" className="font-mono text-xs shrink-0">
|
||||
{formatTimestamp(timestamp)}
|
||||
</Badge>
|
||||
<span className="text-sm">{text}</span>
|
||||
</div>);
|
||||
}
|
||||
if (!line.trim())
|
||||
return null;
|
||||
return (<div key={index} className="py-1">
|
||||
<span className="text-sm">{line}</span>
|
||||
</div>);
|
||||
<span className="text-sm">{line}</span>
|
||||
</div>);
|
||||
}).filter(item => item !== null);
|
||||
};
|
||||
const handleCopyLyrics = async () => {
|
||||
@@ -463,331 +422,318 @@ export function FileManagerPage() {
|
||||
};
|
||||
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
|
||||
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))}>
|
||||
{node.is_dir ? (<>
|
||||
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
|
||||
<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 ? (<>
|
||||
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
|
||||
if (el)
|
||||
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
|
||||
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
|
||||
{node.expanded ? <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"/>
|
||||
</>) : (<>
|
||||
<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"/>
|
||||
</>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
{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"/>
|
||||
</>) : (<>
|
||||
<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"/>
|
||||
</>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
};
|
||||
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
|
||||
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)}>
|
||||
{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"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
<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.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"/>
|
||||
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
};
|
||||
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
|
||||
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)}>
|
||||
{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"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
<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.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"/>
|
||||
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
};
|
||||
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
|
||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-2xl font-bold">File Manager</h1>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<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 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 className="flex items-center gap-2">
|
||||
<Select value={formatPreset} onValueChange={setFormatPreset}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</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="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 className="flex items-center gap-2">
|
||||
<Select value={formatPreset} onValueChange={setFormatPreset}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</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={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-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-4">
|
||||
<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={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-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-4">
|
||||
<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) :
|
||||
<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) :
|
||||
renderCoverTree(filteredFiles))}
|
||||
</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={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Preview</DialogTitle>
|
||||
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 py-4">
|
||||
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
||||
{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>
|
||||
</>)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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={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={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>FFprobe Required</DialogTitle>
|
||||
<DialogDescription>Reading M4A metadata requires FFprobe. Would you like to download and install it now?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>Cancel</Button>
|
||||
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
|
||||
{installingFFprobe ? <><Spinner className="h-4 w-4"/>Installing...</> : "Install FFprobe"}
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Preview</DialogTitle>
|
||||
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 py-4">
|
||||
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
||||
{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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>)}
|
||||
</DialogFooter>
|
||||
</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={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={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={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) => {
|
||||
|
||||
|
||||
|
||||
|
||||
<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={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)
|
||||
handleConfirmManualRename();
|
||||
}}/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
|
||||
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
|
||||
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>);
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
|
||||
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
|
||||
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
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);
|
||||
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
|
||||
useEffect(() => {
|
||||
setDownloadCurrentPage(1);
|
||||
}, [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);
|
||||
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
|
||||
useEffect(() => {
|
||||
setFetchCurrentPage(1);
|
||||
}, [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 { 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
@@ -78,10 +78,16 @@ interface PlaylistInfoProps {
|
||||
external_urls: string;
|
||||
}) => 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">
|
||||
<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">
|
||||
<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"/>)}
|
||||
@@ -100,7 +106,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
|
||||
</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 className="flex gap-2 flex-wrap">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { 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 { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FetchHistory } from "@/components/FetchHistory";
|
||||
@@ -9,6 +9,34 @@ import type { HistoryItem } from "@/components/FetchHistory";
|
||||
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
|
||||
const MAX_RECENT_SEARCHES = 8;
|
||||
@@ -25,8 +53,10 @@ interface SearchBarProps {
|
||||
hasResult: boolean;
|
||||
searchMode: boolean;
|
||||
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 [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
@@ -41,6 +71,8 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
playlists: false,
|
||||
});
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
|
||||
const placeholderText = useTypingEffect(placeholders);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||
@@ -202,172 +234,167 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
{ key: "playlists", label: "Playlists" },
|
||||
];
|
||||
return (<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center bg-muted rounded-md p-1">
|
||||
<button type="button" onClick={() => onSearchModeChange(false)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", !searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")}>
|
||||
<Link className="h-3.5 w-3.5"/>
|
||||
URL
|
||||
</button>
|
||||
<button type="button" onClick={() => onSearchModeChange(true)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")}>
|
||||
<Search className="h-3.5 w-3.5"/>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
|
||||
{searchMode ? <Link className="h-4 w-4"/> : <Search className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
{!searchMode ? (<>
|
||||
<InputWithContext id="spotify-url" placeholder="https://open.spotify.com/..." value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
|
||||
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>) : (<>
|
||||
<InputWithContext id="spotify-search" placeholder="Search tracks, albums, artists..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
||||
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
|
||||
<div className="relative flex-1">
|
||||
{!searchMode ? (<>
|
||||
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
|
||||
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>) : (<>
|
||||
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
||||
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
|
||||
setSearchQuery("");
|
||||
setSearchResults(null);
|
||||
setLastSearchedQuery("");
|
||||
}}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>)}
|
||||
</div>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{!searchMode && (<Button onClick={onFetch} disabled={loading}>
|
||||
{loading ? (<>
|
||||
<Spinner />
|
||||
Fetching...
|
||||
</>) : (<>
|
||||
<CloudDownload className="h-4 w-4"/>
|
||||
Fetch
|
||||
</>)}
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
{!searchMode && (<>
|
||||
<Select value={region} onValueChange={onRegionChange}>
|
||||
<SelectTrigger className="w-[70px] shrink-0">
|
||||
<SelectValue placeholder="Region"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||
{r} <span className="text-muted-foreground">({getRegionName(r)})</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</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">
|
||||
|
||||
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
|
||||
<span>{query}</span>
|
||||
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
|
||||
{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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
|
||||
<span>{query}</span>
|
||||
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRecentSearch(query);
|
||||
}}>
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||
</button>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>)}
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||
</button>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{isSearching && (<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
<span className="ml-2 text-muted-foreground">Searching...</span>
|
||||
</div>)}
|
||||
{isSearching && (<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
<span className="ml-2 text-muted-foreground">Searching...</span>
|
||||
</div>)}
|
||||
|
||||
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
||||
No results found for "{searchQuery}"
|
||||
</div>)}
|
||||
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
||||
No results found for "{searchQuery}"
|
||||
</div>)}
|
||||
|
||||
{!isSearching && hasAnyResults && (<>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
{!isSearching && hasAnyResults && (<>
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const count = getTabCount(tab.key);
|
||||
if (count === 0)
|
||||
return null;
|
||||
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
||||
{tab.label} ({count})
|
||||
</button>);
|
||||
{tab.label} ({count})
|
||||
</button>);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
||||
{activeTab === "tracks" && searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{track.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{formatDuration(track.duration_ms || 0)}
|
||||
</span>
|
||||
</button>))}
|
||||
<div className="grid gap-2">
|
||||
{activeTab === "tracks" &&
|
||||
searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className="font-medium truncate">{track.name}</p>
|
||||
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
|
||||
E
|
||||
</span>)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{track.artists}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{formatDuration(track.duration_ms || 0)}
|
||||
</span>
|
||||
</button>))}
|
||||
|
||||
|
||||
{activeTab === "albums" && searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{album.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{album.release_date || ""}
|
||||
</span>
|
||||
</button>))}
|
||||
{activeTab === "albums" &&
|
||||
searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{album.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{album.artists}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{album.release_date || ""}
|
||||
</span>
|
||||
</button>))}
|
||||
|
||||
|
||||
{activeTab === "artists" && searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{artist.name}</p>
|
||||
<p className="text-sm text-muted-foreground">Artist</p>
|
||||
</div>
|
||||
</button>))}
|
||||
{activeTab === "artists" &&
|
||||
searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{artist.name}</p>
|
||||
<p className="text-sm text-muted-foreground">Artist</p>
|
||||
</div>
|
||||
</button>))}
|
||||
|
||||
|
||||
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{playlist.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{playlist.owner || ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>))}
|
||||
</div>
|
||||
{activeTab === "playlists" &&
|
||||
searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{playlist.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{playlist.owner || ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>))}
|
||||
</div>
|
||||
|
||||
|
||||
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? (<>
|
||||
<Spinner />
|
||||
Loading...
|
||||
</>) : (<>
|
||||
<ChevronDown className="h-4 w-4"/>
|
||||
Load More
|
||||
</>)}
|
||||
</Button>
|
||||
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? (<>
|
||||
<Spinner />
|
||||
Loading...
|
||||
</>) : (<>
|
||||
<ChevronDown className="h-4 w-4"/>
|
||||
Load More
|
||||
</>)}
|
||||
</Button>
|
||||
</div>)}
|
||||
</>)}
|
||||
</div>)}
|
||||
</>)}
|
||||
</div>)}
|
||||
</div>);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,128 +1,137 @@
|
||||
import { HomeIcon } from "@/components/ui/home";
|
||||
import { HistoryIcon } from "@/components/ui/history-icon";
|
||||
import { SettingsIcon } from "@/components/ui/settings";
|
||||
import { ActivityIcon } from "@/components/ui/activity";
|
||||
import { TerminalIcon } from "@/components/ui/terminal";
|
||||
import { FileMusicIcon } from "@/components/ui/file-music";
|
||||
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 { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
|
||||
import BmcLogo from "@/assets/bmc-logo-side.svg";
|
||||
import BmcLogoWhite from "@/assets/bmc-logo-side-white.svg";
|
||||
import KofiLogo from "@/assets/kofi_symbol.svg";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
}
|
||||
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">
|
||||
<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>
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<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")}>
|
||||
<SettingsIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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}>
|
||||
<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")}>
|
||||
<ActivityIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<HistoryIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>History</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<FileMusicIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Converter</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<ActivityIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<FilePenIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<FileMusicIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Converter</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<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")}>
|
||||
<TerminalIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Debug Logs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<FilePenIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<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")}>
|
||||
<TerminalIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Debug Logs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<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")}>
|
||||
<SettingsIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<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")}>
|
||||
<BadgeAlertIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>About</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="relative group">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
|
||||
<CoffeeIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
|
||||
|
||||
<div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
|
||||
|
||||
<div className="absolute left-10 bottom-0 mb-0 ml-3 hidden group-hover:flex flex-col gap-1 p-1 bg-popover border border-border rounded-md shadow-md z-50 w-max animate-in fade-in zoom-in-95 duration-200 origin-bottom-left">
|
||||
<button onClick={() => openExternal("https://ko-fi.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
||||
<img src={KofiLogo} className="h-4 w-4" alt="Ko-fi"/>
|
||||
Support me on Ko-fi
|
||||
</button>
|
||||
<button onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
||||
<img src={BmcLogo} className="h-4 w-4 dark:hidden" alt="BMC"/>
|
||||
<img src={BmcLogoWhite} className="h-4 w-4 hidden dark:block" alt="BMC"/>
|
||||
Buy Me a Coffee
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<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://github.com/afkarxyz/SpotiFLAC/issues/new?title=%5BBug%20Report%5D%20/%20%5BFeature%20Request%5D&body=%3C%21--%20WARNING%3A%20Issues%20that%20do%20not%20follow%20this%20template%20will%20be%20closed%20without%20review.%20Fill%20out%20the%20relevant%20section%20and%20delete%20the%20other.%20--%3E%0A%0A%23%23%23%20%5BBug%20Report%5D%0A%0A%23%23%23%23%20Problem%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%23%20Spotify%20URL%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Version%0ASpotiFLAC%20v%0A%0A%23%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot%0A%0A---%0A%0A%23%23%23%20%5BFeature%20Request%5D%0A%0A%23%23%23%23%20Description%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Use%20Case%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot")}>
|
||||
<GithubIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bug or Feature Request</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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://exyezed.cc/")}>
|
||||
<BlocksIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Other Projects</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Every coffee helps me keep going</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>);
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { X, Minus, Maximize } from "lucide-react";
|
||||
import { X, Minus, Maximize, Settings, Info } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getSettings, updateSettings } from "@/lib/settings";
|
||||
import { useState, useEffect } from "react";
|
||||
export function TitleBar() {
|
||||
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
if (settings) {
|
||||
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
|
||||
}
|
||||
}, []);
|
||||
const handleSpotFetchAPIToggle = () => {
|
||||
const newValue = !useSpotFetchAPI;
|
||||
setUseSpotFetchAPI(newValue);
|
||||
updateSettings({ useSpotFetchAPI: newValue });
|
||||
};
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
@@ -11,11 +27,39 @@ export function TitleBar() {
|
||||
Quit();
|
||||
};
|
||||
return (<>
|
||||
|
||||
|
||||
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
|
||||
|
||||
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||
|
||||
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5 items-center">
|
||||
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||
<Settings className="w-3.5 h-3.5"/>
|
||||
</MenubarTrigger>
|
||||
<MenubarContent align="end" className="min-w-[200px]">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<MenubarLabel className="p-0">SpotFetch API</MenubarLabel>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help text-muted-foreground"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="font-semibold mb-2">Spotify Blocked Countries:</p>
|
||||
<p className="text-xs">Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={handleSpotFetchAPIToggle} className="justify-between">
|
||||
<span>Use SpotFetch API</span>
|
||||
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
|
||||
<Minus className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
|
||||
@@ -31,8 +31,9 @@ interface TrackInfoProps {
|
||||
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;
|
||||
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 minutes = Math.floor(ms / 60000);
|
||||
@@ -45,7 +46,12 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
return plays;
|
||||
return num.toLocaleString();
|
||||
};
|
||||
return (<Card>
|
||||
return (<Card className="relative">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<XCircle className="h-5 w-5"/>
|
||||
</Button>
|
||||
</div>)}
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
@@ -60,6 +66,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
<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>
|
||||
|
||||
@@ -221,6 +221,8 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
{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>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -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 };
|
||||
@@ -10,6 +10,7 @@ export interface CoffeeIconHandle {
|
||||
}
|
||||
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
const PATH_VARIANTS: Variants = {
|
||||
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 isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -55,12 +56,12 @@ const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter
|
||||
}, [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" style={{ overflow: 'visible' }}>
|
||||
<motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/>
|
||||
<motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/>
|
||||
<motion.path d="M6 2v2" 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"/>
|
||||
<motion.path d="M10 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.2}/>
|
||||
<motion.path d="M14 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.4}/>
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>);
|
||||
</div>);
|
||||
});
|
||||
CoffeeIcon.displayName = 'CoffeeIcon';
|
||||
export { CoffeeIcon };
|
||||
|
||||
@@ -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,60 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
const Menubar = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Root>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>>(({ className, ...props }, ref) => (<MenubarPrimitive.Root ref={ref} className={cn("flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", className)} {...props}/>));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
const MenubarTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>>(({ className, ...props }, ref) => (<MenubarPrimitive.Trigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className)} {...props}/>));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
const MenubarSubTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubTrigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}>(({ className, inset, children, ...props }, ref) => (<MenubarPrimitive.SubTrigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", inset && "pl-8", className)} {...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4"/>
|
||||
</MenubarPrimitive.SubTrigger>));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
const MenubarSubContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubContent>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>>(({ className, ...props }, ref) => (<MenubarPrimitive.SubContent ref={ref} className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className)} {...props}/>));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
const MenubarContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Content>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content ref={ref} align={align} alignOffset={alignOffset} sideOffset={sideOffset} className={cn("z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in slide-in-from-top-1", className)} {...props}/>
|
||||
</MenubarPrimitive.Portal>));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
const MenubarItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Item>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className)} {...props}/>));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
const MenubarCheckboxItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>>(({ className, children, checked, ...props }, ref) => (<MenubarPrimitive.CheckboxItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} checked={checked} {...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4"/>
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
const MenubarRadioItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.RadioItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>>(({ className, children, ...props }, ref) => (<MenubarPrimitive.RadioItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current"/>
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
const MenubarLabel = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Label>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} {...props}/>));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
const MenubarSeparator = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>>(({ className, ...props }, ref) => (<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props}/>));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarSeparator, MenubarLabel, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarPortal, MenubarSubContent, MenubarSubTrigger, MenubarSub, MenubarGroup, MenubarShortcut, };
|
||||
@@ -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> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
const LINE_VARIANTS: Variants = {
|
||||
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 isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -50,10 +51,10 @@ const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMous
|
||||
}, [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">
|
||||
<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"/>
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={loop ? 'animate' : controls} initial="normal"/>
|
||||
</svg>
|
||||
</div>);
|
||||
</div>);
|
||||
});
|
||||
TerminalIcon.displayName = '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 TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -28,11 +28,13 @@ export function useCover() {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const yearValue = releaseDate?.substring(0, 4);
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: yearValue,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -124,11 +126,13 @@ export function useCover() {
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
const yearValue = track.release_date?.substring(0, 4);
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
year: yearValue,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
|
||||
@@ -5,6 +5,13 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
function getFirstArtist(artistString: string): string {
|
||||
if (!artistString)
|
||||
return artistString;
|
||||
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
|
||||
const parts = artistString.split(delimiters);
|
||||
return parts[0].trim();
|
||||
}
|
||||
interface CheckFileExistenceRequest {
|
||||
spotify_id: string;
|
||||
track_name: string;
|
||||
@@ -19,6 +26,7 @@ interface CheckFileExistenceRequest {
|
||||
filename_format?: string;
|
||||
include_track_number?: boolean;
|
||||
audio_format?: string;
|
||||
relative_path?: string;
|
||||
}
|
||||
interface FileExistenceResult {
|
||||
spotify_id: string;
|
||||
@@ -27,9 +35,10 @@ interface FileExistenceResult {
|
||||
track_name?: string;
|
||||
artist_name?: string;
|
||||
}
|
||||
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
|
||||
const CheckFilesExistence = (outputDir: string, rootDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, rootDir, tracks);
|
||||
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
|
||||
export function useDownload() {
|
||||
const CreateM3U8File = (playlistName: string, outputDir: string, filePaths: string[]): Promise<void> => (window as any)["go"]["main"]["App"]["CreateM3U8File"](playlistName, outputDir, filePaths);
|
||||
export function useDownload(region: string) {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||
@@ -73,10 +82,16 @@ export function useDownload() {
|
||||
if (hasSubfolder) {
|
||||
useAlbumTrackNumber = true;
|
||||
}
|
||||
const displayArtist = settings.useFirstArtistOnly && artistName
|
||||
? getFirstArtist(artistName)
|
||||
: artistName;
|
||||
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
|
||||
? getFirstArtist(albumArtist)
|
||||
: albumArtist;
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
artist: displayArtist?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
|
||||
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: trackNumberForTemplate,
|
||||
year: yearValue,
|
||||
@@ -84,7 +99,7 @@ export function useDownload() {
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||
if (playlistName && !useAlbumSubfolder) {
|
||||
if (settings.createPlaylistFolder && playlistName && !useAlbumSubfolder) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -104,9 +119,9 @@ export function useDownload() {
|
||||
const checkRequest: CheckFileExistenceRequest = {
|
||||
spotify_id: spotifyId || isrc,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
artist_name: displayArtist || "",
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: finalReleaseDate || releaseDate,
|
||||
track_number: finalTrackNumber || spotifyTrackNumber || 0,
|
||||
disc_number: spotifyDiscNumber || 0,
|
||||
@@ -116,7 +131,7 @@ export function useDownload() {
|
||||
include_track_number: settings.trackNumber || false,
|
||||
audio_format: serviceForCheck,
|
||||
};
|
||||
const existenceResults = await CheckFilesExistence(outputDir, [checkRequest]);
|
||||
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, [checkRequest]);
|
||||
if (existenceResults.length > 0 && existenceResults[0].exists) {
|
||||
fileExists = true;
|
||||
return {
|
||||
@@ -134,14 +149,14 @@ export function useDownload() {
|
||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||
let itemID: string | undefined;
|
||||
if (!fileExists) {
|
||||
itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
|
||||
itemID = await AddToDownloadQueue(isrc, trackName || "", displayArtist || "", albumName || "");
|
||||
}
|
||||
if (service === "auto") {
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId);
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
streamingURLs = JSON.parse(urlsJson);
|
||||
}
|
||||
catch (err) {
|
||||
@@ -149,122 +164,146 @@ export function useDownload() {
|
||||
}
|
||||
}
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
if (streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||
const tidalResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: "tidal",
|
||||
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.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: settings.tidalQuality || "LOSSLESS",
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
spotify_total_discs: spotifyTotalDiscs,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
});
|
||||
if (tidalResponse.success) {
|
||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||
return tidalResponse;
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
const qobuzQuality = is24Bit ? "7" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
isrc,
|
||||
service: "tidal",
|
||||
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.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
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(`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) {
|
||||
logger.error(`tidal error: ${tidalErr}`);
|
||||
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||
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) {
|
||||
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) {
|
||||
if (itemID) {
|
||||
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;
|
||||
let audioFormat: string | undefined;
|
||||
@@ -348,8 +387,9 @@ export function useDownload() {
|
||||
year: yearValue,
|
||||
playlist: folderName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||
if (folderName && (!isAlbum || !useAlbumTag)) {
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -367,7 +407,7 @@ export function useDownload() {
|
||||
if (spotifyId) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId);
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
streamingURLs = JSON.parse(urlsJson);
|
||||
}
|
||||
catch (err) {
|
||||
@@ -375,115 +415,138 @@ export function useDownload() {
|
||||
}
|
||||
}
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
if (streamingURLs?.tidal_url) {
|
||||
try {
|
||||
const tidalResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: "tidal",
|
||||
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.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: settings.tidalQuality || "LOSSLESS",
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
spotify_total_discs: spotifyTotalDiscs,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
});
|
||||
if (tidalResponse.success) {
|
||||
return tidalResponse;
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
const qobuzQuality = is24Bit ? "7" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
const response = await downloadTrack({
|
||||
isrc,
|
||||
service: "tidal",
|
||||
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.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
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("Tidal error:", err);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
catch (tidalErr) {
|
||||
console.error("Tidal error:", tidalErr);
|
||||
}
|
||||
}
|
||||
if (streamingURLs?.amazon_url) {
|
||||
try {
|
||||
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) {
|
||||
return amazonResponse;
|
||||
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||
try {
|
||||
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) {
|
||||
return response;
|
||||
}
|
||||
lastResponse = response;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Amazon error:", err);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
catch (amazonErr) {
|
||||
console.error("Amazon error:", amazonErr);
|
||||
else if (s === "qobuz") {
|
||||
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({
|
||||
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) {
|
||||
if (!lastResponse.success && itemID) {
|
||||
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;
|
||||
let audioFormat: string | undefined;
|
||||
@@ -579,7 +642,7 @@ export function useDownload() {
|
||||
let outputDir = settings.downloadPath;
|
||||
const os = settings.operatingSystem;
|
||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||
if (folderName && (!isAlbum || !useAlbumTag)) {
|
||||
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumTag)) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||
}
|
||||
const selectedTrackObjects = selectedTracks
|
||||
@@ -605,13 +668,15 @@ export function useDownload() {
|
||||
audio_format: audioFormat,
|
||||
};
|
||||
});
|
||||
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
|
||||
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
|
||||
const existingSpotifyIDs = new Set<string>();
|
||||
const existingFilePaths = new Map<string, string>();
|
||||
const finalFilePaths = new Map<string, string>();
|
||||
for (const result of existenceResults) {
|
||||
if (result.exists) {
|
||||
existingSpotifyIDs.add(result.spotify_id);
|
||||
existingFilePaths.set(result.spotify_id, result.file_path || "");
|
||||
finalFilePaths.set(result.spotify_id, result.file_path || "");
|
||||
}
|
||||
}
|
||||
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
||||
@@ -662,6 +727,10 @@ export function useDownload() {
|
||||
successCount++;
|
||||
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
||||
}
|
||||
if (response.file) {
|
||||
finalFilePaths.set(isrc, response.file);
|
||||
finalFilePaths.set(track.spotify_id || isrc, response.file);
|
||||
}
|
||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||
setFailedTracks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -694,6 +763,20 @@ export function useDownload() {
|
||||
shouldStopDownloadRef.current = false;
|
||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||
await CancelAllQueuedItems();
|
||||
if (settings.createM3u8File && folderName) {
|
||||
const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || t.isrc) || "").filter((p) => p !== "");
|
||||
if (paths.length > 0) {
|
||||
try {
|
||||
logger.info(`creating m3u8 playlist: ${folderName}`);
|
||||
await CreateM3U8File(folderName, outputDir, paths);
|
||||
toast.success("M3U8 playlist created");
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`failed to create m3u8 playlist: ${err}`);
|
||||
toast.error(`Failed to create M3U8 playlist: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||
if (errorCount === 0 && skippedCount === 0) {
|
||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||
@@ -728,7 +811,7 @@ export function useDownload() {
|
||||
let outputDir = settings.downloadPath;
|
||||
const os = settings.operatingSystem;
|
||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||
if (folderName && (!isAlbum || !useAlbumTag)) {
|
||||
if (settings.createPlaylistFolder && folderName && (!isAlbum || !useAlbumTag)) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||
}
|
||||
logger.info(`checking existing files in parallel...`);
|
||||
@@ -751,13 +834,16 @@ export function useDownload() {
|
||||
audio_format: audioFormat,
|
||||
};
|
||||
});
|
||||
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
|
||||
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
|
||||
const finalFilePaths: string[] = new Array(tracksWithIsrc.length).fill("");
|
||||
const existingSpotifyIDs = new Set<string>();
|
||||
const existingFilePaths = new Map<string, string>();
|
||||
for (const result of existenceResults) {
|
||||
for (let i = 0; i < existenceResults.length; i++) {
|
||||
const result = existenceResults[i];
|
||||
if (result.exists) {
|
||||
existingSpotifyIDs.add(result.spotify_id);
|
||||
existingFilePaths.set(result.spotify_id, result.file_path || "");
|
||||
finalFilePaths[i] = result.file_path || "";
|
||||
}
|
||||
}
|
||||
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
||||
@@ -812,6 +898,9 @@ export function useDownload() {
|
||||
newSet.delete(track.isrc);
|
||||
return newSet;
|
||||
});
|
||||
if (response.file) {
|
||||
finalFilePaths[originalIndex] = response.file;
|
||||
}
|
||||
}
|
||||
else {
|
||||
errorCount++;
|
||||
@@ -836,6 +925,17 @@ export function useDownload() {
|
||||
shouldStopDownloadRef.current = false;
|
||||
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
||||
await CancelQueued();
|
||||
if (settings.createM3u8File && folderName) {
|
||||
try {
|
||||
logger.info(`creating m3u8 playlist: ${folderName}`);
|
||||
await CreateM3U8File(folderName, outputDir, finalFilePaths.filter(p => p !== ""));
|
||||
toast.success("M3U8 playlist created");
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`failed to create m3u8 playlist: ${err}`);
|
||||
toast.error(`Failed to create M3U8 playlist: ${err}`);
|
||||
}
|
||||
}
|
||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||
if (errorCount === 0 && skippedCount === 0) {
|
||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||
|
||||
@@ -25,11 +25,13 @@ export function useLyrics() {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const yearValue = releaseDate?.substring(0, 4);
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: yearValue,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
@@ -120,11 +122,13 @@ export function useLyrics() {
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
const yearValue = track.release_date?.substring(0, 4);
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
year: yearValue,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
|
||||
@@ -2,13 +2,11 @@ import { useState } from "react";
|
||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { AddFetchHistory } from "../../wailsjs/go/main/App";
|
||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||
export function useMetadata() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 [selectedAlbum, setSelectedAlbum] = useState<{
|
||||
id: string;
|
||||
@@ -27,6 +25,57 @@ export function useMetadata() {
|
||||
return "artist";
|
||||
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 urlType = getUrlType(url);
|
||||
logger.info(`fetching ${urlType} metadata...`);
|
||||
@@ -35,7 +84,8 @@ export function useMetadata() {
|
||||
setMetadata(null);
|
||||
try {
|
||||
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);
|
||||
if ("playlist_info" in data) {
|
||||
const playlistInfo = data.playlist_info;
|
||||
@@ -56,6 +106,7 @@ export function useMetadata() {
|
||||
}
|
||||
}
|
||||
setMetadata(data);
|
||||
saveToHistory(url, data);
|
||||
if ("track" in data) {
|
||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||
@@ -84,6 +135,17 @@ export function useMetadata() {
|
||||
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) => {
|
||||
if (!url.trim()) {
|
||||
logger.warning("empty url provided");
|
||||
@@ -97,43 +159,15 @@ export function useMetadata() {
|
||||
logger.debug("converted to discography url");
|
||||
}
|
||||
if (isArtistUrl) {
|
||||
logger.info("artist url detected, showing timeout dialog");
|
||||
setPendingUrl(urlToFetch);
|
||||
logger.info("artist url detected");
|
||||
setPendingArtistName(null);
|
||||
setShowTimeoutDialog(true);
|
||||
await fetchMetadataDirectly(urlToFetch);
|
||||
}
|
||||
else {
|
||||
await fetchMetadataDirectly(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: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -150,9 +184,8 @@ export function useMetadata() {
|
||||
}) => {
|
||||
logger.debug(`artist clicked: ${artist.name}`);
|
||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setPendingUrl(artistUrl);
|
||||
setPendingArtistName(artist.name);
|
||||
setShowTimeoutDialog(true);
|
||||
await fetchMetadataDirectly(artistUrl);
|
||||
return artistUrl;
|
||||
};
|
||||
const handleConfirmAlbumFetch = async () => {
|
||||
@@ -179,6 +212,7 @@ export function useMetadata() {
|
||||
}
|
||||
}
|
||||
setMetadata(data);
|
||||
saveToHistory(albumUrl, data);
|
||||
if ("album_info" in data) {
|
||||
logger.success(`fetched album: ${data.album_info.name}`);
|
||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||
@@ -200,18 +234,15 @@ export function useMetadata() {
|
||||
return {
|
||||
loading,
|
||||
metadata,
|
||||
showTimeoutDialog,
|
||||
setShowTimeoutDialog,
|
||||
timeoutValue,
|
||||
setTimeoutValue,
|
||||
showAlbumDialog,
|
||||
setShowAlbumDialog,
|
||||
selectedAlbum,
|
||||
pendingArtistName,
|
||||
handleFetchMetadata,
|
||||
handleConfirmFetch,
|
||||
handleAlbumClick,
|
||||
handleConfirmAlbumFetch,
|
||||
handleArtistClick,
|
||||
loadFromCache,
|
||||
resetMetadata: () => setMetadata(null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||
import { 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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class Logger {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message: message.toLowerCase(),
|
||||
message: message,
|
||||
};
|
||||
this.logs.push(entry);
|
||||
if (this.logs.length > this.maxLogs) {
|
||||
|
||||
@@ -23,6 +23,14 @@ export interface Settings {
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7";
|
||||
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;
|
||||
useSpotFetchAPI: boolean;
|
||||
spotFetchAPIUrl: string;
|
||||
createPlaylistFolder: boolean;
|
||||
createM3u8File: boolean;
|
||||
useFirstArtistOnly: boolean;
|
||||
}
|
||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||
label: string;
|
||||
@@ -95,31 +103,39 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
operatingSystem: detectOS(),
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "original"
|
||||
amazonQuality: "original",
|
||||
autoOrder: "tidal-qobuz-amazon",
|
||||
autoQuality: "16",
|
||||
allowFallback: true,
|
||||
useSpotFetchAPI: false,
|
||||
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
|
||||
createPlaylistFolder: true,
|
||||
createM3u8File: false,
|
||||
useFirstArtistOnly: false
|
||||
};
|
||||
export const FONT_OPTIONS: {
|
||||
value: FontFamily;
|
||||
label: 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: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||
];
|
||||
{ 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: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||
];
|
||||
export function applyFont(fontFamily: FontFamily): void {
|
||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||
if (font) {
|
||||
@@ -196,6 +212,15 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
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;
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
}
|
||||
@@ -266,6 +291,24 @@ export async function loadSettings(): Promise<Settings> {
|
||||
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;
|
||||
}
|
||||
if (!('createPlaylistFolder' in parsed)) {
|
||||
parsed.createPlaylistFolder = true;
|
||||
}
|
||||
if (!('createM3u8File' in parsed)) {
|
||||
parsed.createM3u8File = false;
|
||||
}
|
||||
if (!('useFirstArtistOnly' in parsed)) {
|
||||
parsed.useFirstArtistOnly = false;
|
||||
}
|
||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return cachedSettings!;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
export function sanitizePath(input: string, os: string): string {
|
||||
let sanitized = input.trim();
|
||||
const sanitized = input.trim();
|
||||
if (os === "Windows") {
|
||||
return sanitized.replace(/[<>:"/\\|?*]/g, "_");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TrackMetadata {
|
||||
publisher?: string;
|
||||
plays?: string;
|
||||
status?: string;
|
||||
is_explicit?: boolean;
|
||||
}
|
||||
export interface TrackResponse {
|
||||
track: TrackMetadata;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -1,7 +1,11 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
const wailsJsonPath = path.resolve(__dirname, "../wails.json");
|
||||
const wailsJson = JSON.parse(fs.readFileSync(wailsJsonPath, "utf-8"));
|
||||
const appVersion = wailsJson.info.productVersion;
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
@@ -9,4 +13,7 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -86,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/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
@@ -99,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/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.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-20200810151505-1b9f1253b3ed/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},
|
||||
OnStartup: app.startup,
|
||||
OnShutdown: app.shutdown,
|
||||
DragAndDrop: &options.DragAndDrop{
|
||||
EnableFileDrop: true,
|
||||
DisableWebViewDrop: false,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "7.0.5",
|
||||
"productVersion": "7.0.8",
|
||||
"copyright": "© 2026 afkarxyz"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
|
||||