Compare commits

..

20 Commits

Author SHA1 Message Date
afkarxyz 74001462b4 v7.1.0 2026-02-25 14:39:52 +07:00
Blake L. fdca1ab461 feat: Enhance ArtistInfo component with album selection and download options (#493) 2026-02-25 14:22:42 +07:00
afkarxyz 3d8ff2cedd v7.1.0 2026-02-25 14:20:48 +07:00
afkarxyz 9ef24f5a91 v7.1.0 2026-02-24 18:42:22 +07:00
afkarxyz 1314c14c59 . 2026-02-12 19:38:31 +07:00
afkarxyz cb3a6a32cb v7.0.9 2026-02-12 01:08:44 +07:00
afkarxyz df56049db2 v7.0.8 2026-02-10 21:18:05 +07:00
Yuval 36a77ad8d1 feat: Enhance GetFFmpegPath and GetFFprobePath to search the system PATH for executables. (#462) 2026-02-10 21:00:01 +07:00
diego2glez 71bce5d33e fix: add year field to lyrics and cover download template data (#453)
- Extract year from releaseDate using substring(0, 4) in both hooks
- Add year field to templateData in single download functions
- Add year field to templateData in bulk download functions
- Allows parseTemplate() to correctly replace {year} placeholder instead of defaulting to '0000'
- Fixes folder structure generation when year is used in filename or folder templates

Co-authored-by: Diego Glez <diego@example.com>
2026-02-10 20:58:41 +07:00
afkarxyz b74dec7369 .channel and community 2026-01-29 17:12:05 +07:00
afkarxyz d5c5f34d4c .telegram 2026-01-28 19:38:02 +07:00
Zarz Eleutherius 27be5c1b91 Add Telegram links to README (#402)
Added Telegram channel and community links to README.
2026-01-28 19:14:08 +07:00
afkarxyz 0c41d72ab2 v7.0.7 2026-01-27 06:34:11 +07:00
afkarxyz 25233349b9 v7.0.7 2026-01-27 06:16:05 +07:00
afkarxyz e04f6e4fdd .credits 2026-01-15 20:03:48 +07:00
afkarxyz 24bcc56a8f .credits 2026-01-15 19:58:23 +07:00
afkarxyz 45ad82bb66 .credits 2026-01-15 19:15:10 +07:00
afkarxyz 13fcb5787d Create FUNDING.yml 2026-01-15 17:57:32 +07:00
afkarxyz 556e720574 Delete .github/workflows/FUNDING.yml 2026-01-15 17:57:15 +07:00
afkarxyz 791553bdc0 .ko-fi 2026-01-15 17:56:48 +07:00
66 changed files with 7305 additions and 3906 deletions
+2
View File
@@ -0,0 +1,2 @@
github: afkarxyz
ko_fi: afkarxyz
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: '1.25.5'
GO_VERSION: '1.26'
NODE_VERSION: '24'
jobs:
+19 -7
View File
@@ -1,12 +1,12 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
<!-- ![Maintenance](https://maintenance.afkarxyz.fun?v=3) -->
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<div align="center">
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -20,20 +20,27 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
## Screenshot
![Image](https://github.com/user-attachments/assets/eba25a4a-0eb9-4d88-9646-80c7658a7de6)
![Image](https://github.com/user-attachments/assets/adbdc056-bace-44a9-8ba6-898b4526b65a)
## 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)
## FAQ (Frequently Asked Questions)
## FAQ
### Is this software free?
@@ -78,12 +85,17 @@ 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
[MusicBrainz](https://musicbrainz.org) · [Spotify Lyrics API](https://github.akashrchandran.in/spotify-lyrics-api) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) · [yoinkify.lol](https://yoinkify.lol)
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
+364 -125
View File
@@ -8,21 +8,15 @@ import (
"os"
"path/filepath"
"regexp"
"spotiflac/backend"
"strings"
"time"
"github.com/afkarxyz/SpotiFLAC/backend"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var isrcRegex = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$`)
func isValidISRC(isrc string) bool {
return isrcRegex.MatchString(isrc)
}
type App struct {
ctx context.Context
}
@@ -31,6 +25,19 @@ func NewApp() *App {
return &App{}
}
func (a *App) getFirstArtist(artistString string) string {
if artistString == "" {
return ""
}
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
for _, d := range delimiters {
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
return strings.TrimSpace(artistString[:idx])
}
}
return artistString
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
@@ -51,7 +58,6 @@ type SpotifyMetadataRequest struct {
}
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
Query string `json:"query,omitempty"`
TrackName string `json:"track_name,omitempty"`
@@ -79,6 +85,12 @@ 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"`
UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"`
UseSingleGenre bool `json:"use_single_genre,omitempty"`
EmbedGenre bool `json:"embed_genre,omitempty"`
}
type DownloadResponse struct {
@@ -90,14 +102,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
}
@@ -125,6 +137,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)
@@ -186,7 +219,7 @@ func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.Sea
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.Service == "qobuz" && req.ISRC == "" && req.SpotifyID == "" {
if req.Service == "qobuz" && req.SpotifyID == "" {
return DownloadResponse{
Success: false,
Error: "Spotify ID is required for Qobuz",
@@ -201,7 +234,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 == "" {
@@ -281,7 +319,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 {
@@ -297,94 +335,76 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
}
lyricsChan := make(chan string, 1)
isrcChan := make(chan string, 1)
if req.SpotifyID != "" {
if req.EmbedLyrics {
go func() {
client := backend.NewLyricsClient()
resp, _, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.Duration)
if err == nil && resp != nil && len(resp.Lines) > 0 {
lrc := client.ConvertToLRC(resp, req.TrackName, req.ArtistName)
lyricsChan <- lrc
} else {
lyricsChan <- ""
}
}()
} else {
close(lyricsChan)
}
go func() {
client := backend.NewSongLinkClient()
isrc, _ := client.GetISRC(req.SpotifyID)
isrcChan <- isrc
}()
} else {
close(lyricsChan)
close(isrcChan)
}
switch req.Service {
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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
} 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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else {
if req.SpotifyID == "" {
return DownloadResponse{
Success: false,
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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
}
}
case "qobuz":
downloader := backend.NewQobuzDownloader()
fmt.Println("Waiting for ISRC (Qobuz dependency)...")
isrc := <-isrcChan
downloader := backend.NewQobuzDownloader()
quality := req.AudioFormat
if quality == "" {
quality = "6"
}
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, 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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
deezerISRC := req.ISRC
if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) {
deezerISRC = ""
}
if deezerISRC == "" && req.SpotifyID != "" {
songlinkClient := backend.NewSongLinkClient()
deezerURL, err := songlinkClient.GetDeezerURLFromSpotify(req.SpotifyID)
if err != nil {
return DownloadResponse{
Success: false,
Error: fmt.Sprintf("Failed to get Deezer URL: %v", err),
}, err
}
deezerISRC, err = backend.GetDeezerISRC(deezerURL)
if err != nil {
return DownloadResponse{
Success: false,
Error: fmt.Sprintf("Failed to get ISRC from Deezer: %v", err),
}, err
}
}
if deezerISRC == "" {
return DownloadResponse{
Success: false,
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)
case "deezer":
downloader := backend.NewDeezerDownloader()
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, 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, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
default:
return DownloadResponse{
@@ -419,53 +439,30 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
filename = strings.TrimPrefix(filename, "EXISTS:")
}
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") {
go func(filePath, spotifyID, trackName, artistName string) {
fmt.Printf("\n========== LYRICS FETCH START ==========\n")
fmt.Printf("Spotify ID: %s\n", spotifyID)
fmt.Printf("Track: %s\n", trackName)
fmt.Printf("Artist: %s\n", artistName)
fmt.Println("Searching all sources...")
lyricsClient := backend.NewLyricsClient()
lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName, 0)
if err != nil {
fmt.Printf("All sources failed: %v\n", err)
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
return
}
if lyricsResp == nil || len(lyricsResp.Lines) == 0 {
fmt.Println("No lyrics content found")
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
return
}
fmt.Printf("Lyrics found from: %s\n", source)
fmt.Printf("Sync type: %s\n", lyricsResp.SyncType)
fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines))
lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName)
if lyrics == "" {
fmt.Println("No lyrics content to embed")
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
return
}
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) {
fmt.Printf("\nWaiting for lyrics fetch to complete...\n")
lyrics := <-lyricsChan
if lyrics != "" {
fmt.Printf("\n--- Full LRC Content ---\n")
fmt.Println(lyrics)
fmt.Printf("--- End LRC Content ---\n\n")
fmt.Printf("Embedding into: %s\n", filePath)
if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil {
fmt.Printf("Embedding into: %s\n", filename)
if err := backend.EmbedLyricsOnlyUniversal(filename, lyrics); err != nil {
fmt.Printf("Failed to embed lyrics: %v\n", err)
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
} else {
fmt.Printf("Lyrics embedded successfully!\n")
fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n")
}
}(filename, req.SpotifyID, req.TrackName, req.ArtistName)
} else {
fmt.Println("No lyrics found to embed.")
}
} else {
select {
case <-lyricsChan:
default:
}
}
message := "Download completed successfully"
@@ -488,11 +485,17 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
meta, err := backend.GetTrackMetadata(fPath)
if err == nil && meta != nil {
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
if meta.BitsPerSample > 0 {
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
} else if meta.Bitrate > 0 {
quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0)
} else if meta.SampleRate > 0 {
quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0)
}
d := int(meta.Duration)
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
} else {
fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err)
}
item := backend.HistoryItem{
@@ -503,7 +506,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
DurationStr: durationStr,
CoverURL: cover,
Quality: quality,
Format: format,
Format: strings.ToUpper(format),
Path: fPath,
}
@@ -513,6 +516,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
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)
}
@@ -569,9 +578,9 @@ func (a *App) ClearAllDownloads() {
backend.ClearAllDownloads()
}
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
func (a *App) AddToDownloadQueue(spotifyID, trackName, artistName, albumName string) string {
itemID := fmt.Sprintf("%s-%d", spotifyID, time.Now().UnixNano())
backend.AddToQueue(itemID, trackName, artistName, albumName, "")
return itemID
}
@@ -583,6 +592,74 @@ 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.SpotifyID != "" {
failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.SpotifyID))
failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.SpotifyID))
}
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")
@@ -596,6 +673,30 @@ 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")
@@ -855,13 +956,13 @@ func (a *App) DownloadAvatar(req AvatarDownloadRequest) (backend.AvatarDownloadR
return *resp, nil
}
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) {
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
client := backend.NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyTrackID, isrc)
availability, err := client.CheckTrackAvailability(spotifyTrackID)
if err != nil {
return "", err
}
@@ -987,6 +1088,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 {
@@ -1026,6 +1148,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 {
@@ -1036,12 +1159,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"
@@ -1052,6 +1178,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{
@@ -1088,6 +1238,8 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
t.AlbumArtist,
t.ReleaseDate,
filenameFormat,
"",
"",
t.IncludeTrackNumber,
trackNumber,
t.DiscNumber,
@@ -1096,11 +1248,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}
@@ -1108,9 +1268,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
@@ -1183,3 +1373,52 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
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
}
+225 -447
View File
@@ -1,17 +1,13 @@
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 +15,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 +25,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 +35,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/145.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)
@@ -185,8 +95,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 {
trackAsin := strings.Split(parts[1], "&")[0]
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=")
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
}
}
@@ -194,374 +103,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://amzn.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/145.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/145.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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -570,7 +268,13 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
}
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
filenameArtist := spotifyArtistName
filenameAlbumArtist := spotifyAlbumArtist
if useFirstArtistOnly {
filenameArtist = GetFirstArtist(spotifyArtistName)
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
}
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
@@ -579,6 +283,42 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
}
}
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
@@ -586,11 +326,28 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
return "", err
}
var isrc string
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
}
originalFileDir := filepath.Dir(filePath)
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
year := ""
if len(spotifyReleaseDate) >= 4 {
@@ -606,6 +363,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
@@ -637,7 +395,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 {
@@ -683,25 +445,41 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
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,
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
) (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, useFirstArtistOnly, useSingleGenre, embedGenre)
}
+95 -24
View File
@@ -4,6 +4,10 @@ import (
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/go-flac/go-flac"
mewflac "github.com/mewkiz/flac"
@@ -17,6 +21,7 @@ type AnalysisResult struct {
BitsPerSample uint8 `json:"bits_per_sample"`
TotalSamples uint64 `json:"total_samples"`
Duration float64 `json:"duration"`
Bitrate int `json:"bit_rate"`
BitDepth string `json:"bit_depth"`
DynamicRange float64 `json:"dynamic_range"`
PeakAmplitude float64 `json:"peak_amplitude"`
@@ -168,40 +173,106 @@ func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
fileInfo, err := os.Stat(filepath)
return GetMetadataWithFFprobe(filepath)
}
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
return nil, err
}
f, err := flac.ParseFile(filepath)
for i := 0; i < 5; i++ {
if f, err := os.Open(filePath); err == nil {
f.Close()
break
}
time.Sleep(200 * time.Millisecond)
}
args := []string{
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
}
cmd := exec.Command(ffprobePath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output))
}
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) < 4 {
return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
}
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])
res := &AnalysisResult{
FilePath: filePath,
}
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
if info, err := os.Stat(filePath); err == nil {
res.FileSize = info.Size()
}
infoMap := make(map[string]string)
args = []string{
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
"-of", "default=noprint_wrappers=0",
filePath,
}
cmd = exec.Command(ffprobePath, args...)
setHideWindow(cmd)
output, err = cmd.CombinedOutput()
if err == nil {
lines = strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
if val, ok := infoMap["sample_rate"]; ok {
s, _ := strconv.Atoi(val)
res.SampleRate = uint32(s)
}
if val, ok := infoMap["channels"]; ok {
c, _ := strconv.Atoi(val)
res.Channels = uint8(c)
}
if val, ok := infoMap["duration"]; ok {
d, _ := strconv.ParseFloat(val, 64)
res.Duration = d
}
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
br, _ := strconv.Atoi(val)
res.Bitrate = br
}
bits := 0
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
bits, _ = strconv.Atoi(val)
}
if bits == 0 {
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
bits, _ = strconv.Atoi(val)
}
}
res.BitsPerSample = uint8(bits)
if bits > 0 {
res.BitDepth = fmt.Sprintf("%d-bit", bits)
} else {
res.BitDepth = "Unknown"
}
return res, nil
}
+1
View File
@@ -83,6 +83,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+273
View File
@@ -0,0 +1,273 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type DeezerDownloader struct {
client *http.Client
}
func NewDeezerDownloader() *DeezerDownloader {
return &DeezerDownloader{
client: &http.Client{
Timeout: 300 * time.Second,
},
}
}
type YoinkifyRequest struct {
URL string `json:"url"`
Format string `json:"format"`
GenreSource string `json:"genreSource"`
}
func (d *DeezerDownloader) DownloadFromYoinkify(spotifyURL, outputDir string) (string, error) {
apiURL := "https://yoinkify.lol/api/download"
payload := YoinkifyRequest{
URL: spotifyURL,
Format: "flac",
GenreSource: "spotify",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
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/145.0.0.0 Safari/537.36")
fmt.Printf("Fetching from Deezer API (Yoinkify)...\n")
resp, err := d.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
}
tempFileName := fmt.Sprintf("deezer_%d.flac", time.Now().UnixNano())
filePath := filepath.Join(outputDir, tempFileName)
out, err := os.Create(filePath)
if err != nil {
return "", err
}
defer out.Close()
fmt.Printf("Downloading track from Deezer...\n")
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
out.Close()
os.Remove(filePath)
return "", err
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return filePath, nil
}
func (d *DeezerDownloader) Download(spotifyID, outputDir, 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
if spotifyTrackName != "" && spotifyArtistName != "" {
filenameArtist := spotifyArtistName
filenameAlbumArtist := spotifyAlbumArtist
if useFirstArtistOnly {
filenameArtist = GetFirstArtist(spotifyArtistName)
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
}
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + expectedPath, nil
}
}
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if (embedGenre || true) && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" && embedGenre {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
filePath, err := d.DownloadFromYoinkify(spotifyURL, outputDir)
if err != nil {
return "", err
}
var isrc string
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
}
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
var newFilename string
if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
} else {
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
}
if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
newFilename = strings.ReplaceAll(newFilename, "{track}", "")
}
} else {
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default:
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
}
ext := ".flac"
newFilename = newFilename + ext
newFilePath := filepath.Join(outputDir, newFilename)
if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else {
filePath = newFilePath
fmt.Printf("Renamed to: %s\n", newFilename)
}
}
fmt.Println("Embedding Spotify metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: spotifyTrackName,
Artist: spotifyArtistName,
Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Println("Metadata embedded successfully")
}
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Deezer")
return filePath, nil
}
+72 -49
View File
@@ -3,7 +3,7 @@ package backend
import (
"archive/tar"
"archive/zip"
"encoding/base64"
"fmt"
"io"
"net/http"
@@ -18,14 +18,6 @@ import (
"github.com/ulikunitz/xz"
)
func decodeBase64(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
return string(decoded), nil
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
@@ -65,13 +57,6 @@ func ValidateExecutable(path string) error {
return nil
}
const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -91,7 +76,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 +100,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) {
@@ -146,6 +146,11 @@ func IsFFmpegInstalled() (bool, error) {
return err == nil, nil
}
const (
ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
)
func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0)
@@ -166,54 +171,51 @@ func DownloadFFmpeg(progressCallback func(int)) error {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled {
isARM := runtime.GOARCH == "arm64"
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
var macFFmpegURLs []string
var macFFprobeURLs []string
if isARM {
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
} else {
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
}
if !ffmpegInstalled && !ffprobeInstalled {
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
return err
}
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
return err
}
} else if !ffmpegInstalled {
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
}
return nil
}
var encodedURL string
var url string
switch runtime.GOOS {
case "windows":
encodedURL = ffmpegWindowsURL
url = ffmpegWindowsURL
case "linux":
encodedURL = ffmpegLinuxURL
url = ffmpegLinuxURL
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
url, err := decodeBase64(encodedURL)
if err != nil {
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
}
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
@@ -221,6 +223,20 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil
}
func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error {
var lastErr error
for _, url := range urls {
fmt.Printf("[FFmpeg] Trying to download from: %s\n", url)
err := downloadAndExtract(url, destDir, progressCallback, start, end)
if err == nil {
return nil
}
lastErr = err
fmt.Printf("[FFmpeg] Attempt failed: %v\n", err)
}
return fmt.Errorf("all download attempts failed: %w", lastErr)
}
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
@@ -230,7 +246,14 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
resp, err := http.Get(url)
client := &http.Client{}
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/120.0.0.0 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
+1
View File
@@ -332,6 +332,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
+28 -8
View File
@@ -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,9 @@ 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, "{date}", SanitizeFilename(releaseDate))
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 +70,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, "/", " ")
@@ -113,6 +119,19 @@ func sanitizeFilename(name string) string {
return sanitized
}
func GetFirstArtist(artistString string) string {
if artistString == "" {
return ""
}
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
for _, d := range delimiters {
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
return strings.TrimSpace(artistString[:idx])
}
}
return artistString
}
func NormalizePath(folderPath string) string {
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
@@ -148,7 +167,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)
}
+23
View File
@@ -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
}
+178 -1
View File
@@ -75,7 +75,10 @@ func AddHistoryItem(item HistoryItem, appName string) error {
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
b, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
if err != nil {
return err
}
id, _ := b.NextSequence()
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
@@ -147,3 +150,177 @@ func ClearHistory(appName string) 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))
})
}
+76 -7
View File
@@ -1,7 +1,6 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -38,6 +37,17 @@ type LyricsResponse struct {
Lines []LyricsLine `json:"lines"`
}
type SpotifyLyricsLine struct {
TimeTag string `json:"timeTag"`
Words string `json:"words"`
}
type SpotifyLyricsAPIResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []SpotifyLyricsLine `json:"lines"`
}
type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
@@ -73,9 +83,7 @@ func NewLyricsClient() *LyricsClient {
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
url.QueryEscape(artistName),
url.QueryEscape(trackName))
@@ -167,8 +175,7 @@ func lrcTimestampToMs(timestamp string) int64 {
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
apiURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
resp, err := c.httpClient.Get(apiURL)
if err != nil {
@@ -212,6 +219,61 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return c.convertLRCLibToLyricsResponse(best), nil
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
if spotifyID == "" {
return nil, fmt.Errorf("spotify ID is empty")
}
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", spotifyID)
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %v", err)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %v", err)
}
if apiResp.Error {
return nil, fmt.Errorf("Spotify Lyrics API returned error")
}
result := &LyricsResponse{
Error: false,
SyncType: apiResp.SyncType,
Lines: []LyricsLine{},
}
for _, line := range apiResp.Lines {
if line.TimeTag == "" && line.Words == "" {
continue
}
ms := lrcTimestampToMs(line.TimeTag)
result.Lines = append(result.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
Words: line.Words,
})
}
if len(result.Lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
return result, nil
}
func simplifyTrackName(name string) string {
if idx := strings.Index(name, "("); idx > 0 {
@@ -226,7 +288,13 @@ func simplifyTrackName(name string) string {
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
resp, err := c.FetchLyricsFromSpotifyAPI(spotifyID)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "Spotify", nil
}
fmt.Printf(" Spotify Lyrics API: %v\n", err)
resp, err = c.FetchLyricsWithMetadata(trackName, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil
}
@@ -313,6 +381,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+33 -13
View File
@@ -31,6 +31,8 @@ type Metadata struct {
Publisher string
Lyrics string
Description string
ISRC string
Genre string
}
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
@@ -86,6 +88,14 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
if metadata.ISRC != "" {
_ = cmt.Add("ISRC", metadata.ISRC)
}
if metadata.Genre != "" {
_ = cmt.Add("GENRE", metadata.Genre)
}
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics)
}
@@ -504,6 +514,13 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
return nil
}
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[EmbedLyricsOnlyUniversal] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
ext := strings.ToLower(pathfilepath.Ext(filepath))
switch ext {
case ".mp3":
@@ -635,27 +652,22 @@ func validateLyricsDuration(lyrics string, filepath string) (string, error) {
if strings.HasPrefix(trimmedLine, "[") {
if strings.Index(trimmedLine, ":") > 0 {
validLines = append(validLines, line)
continue
}
closeBracket := strings.Index(trimmedLine, "]")
if closeBracket > 0 {
timestampStr := trimmedLine[1:closeBracket]
ms := parseLRCTimestamp(timestampStr)
if ms >= 0 && ms <= durationMs {
validLines = append(validLines, line)
if ms >= 0 {
if ms <= durationMs {
validLines = append(validLines, line)
} else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
}
} else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
validLines = append(validLines, line)
}
} else {
validLines = append(validLines, line)
continue
}
} else {
@@ -858,6 +870,11 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
}
if metadata.ISRC != "" {
tag.DeleteFrames("TSRC")
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
}
if coverPath != "" && fileExists(coverPath) {
tag.DeleteFrames(tag.CommonID("Attached picture"))
@@ -941,6 +958,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
if metadata.Publisher != "" {
args = append(args, "-metadata", "publisher="+metadata.Publisher)
}
if metadata.ISRC != "" {
args = append(args, "-metadata", "isrc="+metadata.ISRC)
}
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
defer func() {
+154
View File
@@ -0,0 +1,154 @@
package backend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var AppVersion = "Unknown"
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
type MusicBrainzRecordingResponse struct {
Recordings []struct {
ID string `json:"id"`
Title string `json:"title"`
Length int `json:"length"`
Releases []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
ReleaseGroup struct {
ID string `json:"id"`
Title string `json:"title"`
PrimaryType string `json:"primary-type"`
} `json:"release-group"`
Date string `json:"date"`
Country string `json:"country"`
Media []struct {
Format string `json:"format"`
} `json:"media"`
LabelInfo []struct {
Label struct {
Name string `json:"name"`
} `json:"label"`
} `json:"label-info"`
} `json:"releases"`
ArtistCredit []struct {
Artist struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"artist"`
} `json:"artist-credit"`
Tags []struct {
Count int `json:"count"`
Name string `json:"name"`
} `json:"tags"`
} `json:"recordings"`
}
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
var meta Metadata
if !embedGenre {
return meta, nil
}
if isrc == "" {
return meta, fmt.Errorf("no ISRC provided")
}
client := &http.Client{
Timeout: 10 * time.Second,
}
query := fmt.Sprintf("isrc:%s", isrc)
reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query))
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return meta, err
}
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@exyezed.cc )", AppVersion))
var resp *http.Response
var lastErr error
for i := 0; i < 3; i++ {
resp, lastErr = client.Do(req)
if lastErr == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if i < 2 {
time.Sleep(2 * time.Second)
}
}
if lastErr != nil {
return meta, lastErr
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
}
defer resp.Body.Close()
var mbResp MusicBrainzRecordingResponse
if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil {
return meta, err
}
if len(mbResp.Recordings) == 0 {
return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc)
}
recording := mbResp.Recordings[0]
var genres []string
caser := cases.Title(language.English)
if useSingleGenre {
maxCount := -1
var bestTag string
for _, tag := range recording.Tags {
if tag.Count > maxCount {
maxCount = tag.Count
bestTag = tag.Name
}
}
if bestTag != "" {
meta.Genre = caser.String(bestTag)
}
} else {
for _, tag := range recording.Tags {
genres = append(genres, caser.String(tag.Name))
}
if len(genres) > 0 {
if len(genres) > 5 {
genres = genres[:5]
}
meta.Genre = strings.Join(genres, "; ")
}
}
return meta, nil
}
+3 -3
View File
@@ -22,7 +22,7 @@ type DownloadItem struct {
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
ISRC string `json:"isrc"`
SpotifyID string `json:"spotify_id"`
Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"`
TotalSize float64 `json:"total_size"`
@@ -184,7 +184,7 @@ func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
func AddToQueue(id, trackName, artistName, albumName, spotifyID string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -193,7 +193,7 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
TrackName: trackName,
ArtistName: artistName,
AlbumName: albumName,
ISRC: isrc,
SpotifyID: spotifyID,
Status: StatusQueued,
Progress: 0,
TotalSize: 0,
+150 -86
View File
@@ -1,10 +1,10 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"path/filepath"
@@ -77,10 +77,9 @@ 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)
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
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,123 @@ 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"
}
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()
}
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)
resp, err = q.client.Get(fallbackURL)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err == nil && len(body) > 0 {
fmt.Printf("Fallback API #1 response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
fmt.Printf("✓ Got download URL from Fallback API #1\n")
return streamResp.URL, nil
}
}
}
if resp != nil {
resp.Body.Close()
}
fmt.Println("Fallback API #1 failed, trying Fallback API #2...")
fallback2Base, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9xb2J1ei5zcXVpZC53dGYvYXBpL2Rvd25sb2FkLW11c2ljP3RyYWNrX2lkPQ==")
fallback2URL := fmt.Sprintf("%s%d&quality=%s", string(fallback2Base), trackID, qualityCode)
resp, err = q.client.Get(fallback2URL)
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 "", fmt.Errorf("all APIs failed to get download URL: %w", err)
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("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
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=",
}
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)
},
})
}
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 {
@@ -300,6 +318,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
@@ -334,16 +353,48 @@ 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) DownloadTrack(spotifyID, 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
var deezerISRC string
if spotifyID != "" {
songlinkClient := NewSongLinkClient()
isrc, err := songlinkClient.GetISRC(spotifyID)
if err != nil {
return "", fmt.Errorf("failed to get ISRC: %v", err)
}
deezerISRC = isrc
} else {
return "", fmt.Errorf("spotify ID is required for Qobuz download")
}
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
metaChan := make(chan Metadata, 1)
if embedGenre && deezerISRC != "" {
go func() {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
fmt.Println("✓ MusicBrainz metadata fetched")
metaChan <- fetchedMeta
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
metaChan <- Metadata{}
}
}()
} else {
close(metaChan)
}
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(deezerISRC)
track, err := q.searchByISRC(deezerISRC)
if err != nil {
return "", err
}
@@ -362,7 +413,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)
}
@@ -378,9 +429,15 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
fmt.Printf("Download URL obtained: %s\n", urlPreview)
safeArtist := sanitizeFilename(artists)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
safeArtist = sanitizeFilename(GetFirstArtist(artists))
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
@@ -411,6 +468,11 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
}
}
var mbMeta Metadata
if deezerISRC != "" {
mbMeta = <-metaChan
}
fmt.Println("Embedding metadata and cover art...")
trackNumberToEmbed := spotifyTrackNumber
@@ -432,6 +494,8 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: deezerISRC,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
+34 -19
View File
@@ -1,7 +1,6 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -21,6 +20,7 @@ type SongLinkClient struct {
type SongLinkURLs struct {
TidalURL string `json:"tidal_url"`
AmazonURL string `json:"amazon_url"`
ISRC string `json:"isrc"`
}
type TrackAvailability struct {
@@ -28,9 +28,11 @@ type TrackAvailability struct {
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
}
func NewSongLinkClient() *SongLinkClient {
@@ -42,7 +44,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 {
@@ -70,11 +72,13 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
if region != "" {
apiURL += fmt.Sprintf("&userCountry=%s", region)
}
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -154,6 +158,12 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
if isrc, err := getDeezerISRC(deezerLink.URL); err == nil && isrc != "" {
urls.ISRC = isrc
}
}
if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
@@ -161,7 +171,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return urls, nil
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
@@ -189,11 +199,9 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -273,8 +281,10 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL
availability.Deezer = true
availability.DeezerURL = deezerURL
deezerISRC, err := GetDeezerISRC(deezerURL)
deezerISRC, err := getDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable
@@ -288,8 +298,7 @@ func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID)
resp, err := client.Get(searchURL)
if err != nil {
@@ -341,11 +350,9 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string,
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -404,7 +411,7 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string,
return deezerURL, nil
}
func GetDeezerISRC(deezerURL string) (string, error) {
func getDeezerISRC(deezerURL string) (string, error) {
var trackID string
if strings.Contains(deezerURL, "/track/") {
@@ -448,3 +455,11 @@ func GetDeezerISRC(deezerURL string) (string, error) {
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
return deezerTrack.ISRC, nil
}
func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) {
deezerURL, err := s.GetDeezerURLFromSpotify(spotifyID)
if err != nil {
return "", err
}
return getDeezerISRC(deezerURL)
}
+159 -104
View File
@@ -2,9 +2,7 @@ package backend
import (
"bytes"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -41,39 +39,10 @@ func NewSpotifyClient() *SpotifyClient {
}
}
func (c *SpotifyClient) getTOTPSecret() (int, []byte) {
secrets := map[int][]byte{
59: {123, 105, 79, 70, 110, 59, 52, 125, 60, 49, 80, 70, 89, 75, 80, 86, 63, 53, 123, 37, 117, 49, 52, 93, 77, 62, 47, 86, 48, 104, 68, 72},
60: {79, 109, 69, 123, 90, 65, 46, 74, 94, 34, 58, 48, 70, 71, 92, 85, 122, 63, 91, 64, 87, 87},
61: {44, 55, 47, 42, 70, 40, 34, 114, 76, 74, 50, 111, 120, 97, 75, 76, 94, 102, 43, 69, 49, 120, 118, 80, 64, 78},
}
version := 61
secretList := secrets[version]
return version, secretList
}
func (c *SpotifyClient) generateTOTP() (string, int, error) {
version, secretList := c.getTOTPSecret()
transformed := make([]byte, len(secretList))
for i, b := range secretList {
transformed[i] = b ^ byte((i%33)+9)
}
var joined strings.Builder
for _, b := range transformed {
joined.WriteString(strconv.Itoa(int(b)))
}
hexStr := hex.EncodeToString([]byte(joined.String()))
hexBytes, err := hex.DecodeString(hexStr)
if err != nil {
return "", 0, err
}
secret := base32Encode(hexBytes)
secret = strings.TrimRight(secret, "=")
secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
version := 61
key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret))
if err != nil {
@@ -88,11 +57,6 @@ func (c *SpotifyClient) generateTOTP() (string, int, error) {
return totpCode, version, nil
}
func base32Encode(data []byte) string {
b32 := base32.StdEncoding.WithPadding(base32.NoPadding)
return b32.EncodeToString(data)
}
func (c *SpotifyClient) getAccessToken() error {
totpCode, version, err := c.generateTOTP()
if err != nil {
@@ -112,7 +76,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/145.0.0.0 Safari/537.36")
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
resp, err := c.client.Do(req)
@@ -149,7 +113,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/145.0.0.0 Safari/537.36")
for name, value := range c.cookies {
req.AddCookie(&http.Cookie{Name: name, Value: value})
@@ -230,7 +194,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/145.0.0.0 Safari/537.36")
resp, err := c.client.Do(req)
if err != nil {
@@ -288,7 +252,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/145.0.0.0 Safari/537.36")
resp, err := c.client.Do(req)
if err != nil {
@@ -364,9 +328,6 @@ func getBool(m map[string]interface{}, key string) bool {
func extractArtists(artistsData map[string]interface{}) []map[string]interface{} {
items := getSlice(artistsData, "items")
if items == nil {
return []map[string]interface{}{}
}
artists := []map[string]interface{}{}
for _, item := range items {
@@ -384,7 +345,7 @@ func extractArtists(artistsData map[string]interface{}) []map[string]interface{}
}
func extractCoverImage(coverData map[string]interface{}) map[string]interface{} {
if coverData == nil || len(coverData) == 0 {
if len(coverData) == 0 {
return nil
}
@@ -401,7 +362,7 @@ func extractCoverImage(coverData map[string]interface{}) map[string]interface{}
}
}
if sources == nil || len(sources) == 0 {
if len(sources) == 0 {
return nil
}
@@ -532,7 +493,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
}
var albumFetchDataMap map[string]interface{}
if len(albumFetchData) > 0 && albumFetchData[0] != nil {
if len(albumFetchData) > 0 {
albumFetchDataMap = albumFetchData[0]
}
@@ -541,39 +502,35 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
if len(artists) == 0 {
artists = []map[string]interface{}{}
firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items")
if firstArtistItems != nil {
for _, item := range firstArtistItems {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if profile, exists := itemMap["profile"]; exists {
profileMap, ok := profile.(map[string]interface{})
if ok {
artistInfo := map[string]interface{}{
"name": getString(profileMap, "name"),
}
artists = append(artists, artistInfo)
for _, item := range firstArtistItems {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if profile, exists := itemMap["profile"]; exists {
profileMap, ok := profile.(map[string]interface{})
if ok {
artistInfo := map[string]interface{}{
"name": getString(profileMap, "name"),
}
artists = append(artists, artistInfo)
}
}
}
otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items")
if otherArtistItems != nil {
for _, item := range otherArtistItems {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if profile, exists := itemMap["profile"]; exists {
profileMap, ok := profile.(map[string]interface{})
if ok {
artistInfo := map[string]interface{}{
"name": getString(profileMap, "name"),
}
artists = append(artists, artistInfo)
for _, item := range otherArtistItems {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if profile, exists := itemMap["profile"]; exists {
profileMap, ok := profile.(map[string]interface{})
if ok {
artistInfo := map[string]interface{}{
"name": getString(profileMap, "name"),
}
artists = append(artists, artistInfo)
}
}
}
@@ -710,6 +667,9 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
}
albumArtistsString = strings.Join(albumArtistNames, ", ")
}
if albumArtistsString == "" {
albumArtistsString = getString(albumUnionData, "artists")
}
albumLabel = getString(albumUnionData, "label")
}
}
@@ -767,23 +727,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
@@ -871,13 +884,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)
}
@@ -897,6 +920,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"),
@@ -905,6 +934,10 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"releaseDate": releaseDate,
"count": len(tracks),
"tracks": tracks,
"discs": map[string]interface{}{
"totalCount": totalDiscs,
},
"label": getString(albumData, "label"),
}
return filtered
@@ -1092,10 +1125,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,
@@ -1104,6 +1145,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)
}
@@ -1197,12 +1240,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"),
}
}
@@ -1472,14 +1523,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
}
+101
View File
@@ -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 "", ""
}
+170 -95
View File
@@ -42,11 +42,11 @@ type TrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
Copyright string `json:"copyright,omitempty"`
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 {
@@ -69,7 +69,6 @@ type AlbumTrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"`
TotalDiscs int `json:"total_discs,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumType string `json:"album_type,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
@@ -79,6 +78,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 +198,7 @@ type apiTrackResponse struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"cover"`
IsExplicit bool `json:"is_explicit"`
}
type apiAlbumResponse struct {
@@ -207,13 +208,19 @@ 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"`
Label string `json:"label"`
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 +247,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 +270,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 +285,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 +332,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 +436,47 @@ 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,
},
"artists": albumResponse.Artists,
"label": albumResponse.Label,
},
},
},
}
}
albumFetchData, _ = client.Query(albumPayload)
}
}
}
@@ -464,6 +502,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 +769,12 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
}
offset += limit
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
albumsItems := []interface{}{}
@@ -839,10 +887,10 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
DiscNumber: raw.Disc,
TotalDiscs: raw.Discs,
ExternalURL: externalURL,
ISRC: raw.ID,
Copyright: raw.Copyright,
Publisher: raw.Album.Label,
Plays: raw.Plays,
IsExplicit: raw.IsExplicit,
}
return TrackResponse{
@@ -894,16 +942,16 @@ 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,
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
ArtistID: artistID,
ArtistURL: artistURL,
ArtistsData: artistsData,
Plays: item.Plays,
IsExplicit: item.IsExplicit,
})
}
@@ -953,10 +1001,9 @@ 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,
AlbumID: item.AlbumID,
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
ArtistID: artistID,
@@ -964,6 +1011,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
ArtistsData: artistsData,
Plays: item.Plays,
Status: item.Status,
IsExplicit: item.IsExplicit,
})
}
@@ -995,79 +1043,104 @@ 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),
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 +1319,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 +1433,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":
+335 -330
View File
@@ -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,36 @@ 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",
}
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/145.0.0.0 Safari/537.36")
fmt.Println("Getting Tidal URL...")
resp, err := t.client.Do(req)
@@ -237,49 +151,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/145.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 +216,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/145.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 +259,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 +268,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/145.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 +307,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 +444,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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err)
@@ -542,12 +458,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")
}
@@ -556,11 +467,17 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
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 +485,53 @@ 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
}
}
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
fmt.Printf("Downloading to: %s\n", outputFilename)
@@ -578,6 +539,14 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return "", err
}
var isrc string
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
}
fmt.Println("Adding metadata...")
coverPath := ""
@@ -613,6 +582,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
@@ -626,7 +597,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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
apis, err := t.GetAvailableAPIs()
if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err)
@@ -645,12 +616,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")
}
@@ -659,11 +625,17 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName)
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
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 +643,53 @@ 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
}
}
type mbResultFallback struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResultFallback, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResultFallback{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
fmt.Printf("Downloading to: %s\n", outputFilename)
@@ -682,6 +698,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "", err
}
var isrc string
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
isrc = result.ISRC
mbMeta = result.Metadata
}
fmt.Println("Adding metadata...")
coverPath := ""
@@ -717,6 +741,8 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
@@ -730,14 +756,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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre 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, useFirstArtistOnly, useSingleGenre, embedGenre)
}
type SegmentTemplate struct {
@@ -768,10 +794,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 +805,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 +878,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 +894,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, "&amp;", "&")
@@ -889,7 +915,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 +925,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:")
@@ -1015,6 +1019,7 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
+217
View File
@@ -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/145.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/145.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/145.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("![image](%s)", 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("![image](%s)", matches[1]), nil
}
reImg := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']*?\.send\.now/i/[^"']+)["']`)
matchesImg := reImg.FindStringSubmatch(htmlStr)
if len(matchesImg) > 1 {
return fmt.Sprintf("![image](%s)", 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("![image](%s)", 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("![image](%s)", link), nil
}
}
}
return fmt.Sprintf("[View File](%s)", url), nil
}
+17 -15
View File
@@ -16,7 +16,9 @@
"@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",
@@ -24,32 +26,32 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"lucide-react": "^0.575.0",
"motion": "^12.34.3",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.8",
"@types/react": "^19.2.8",
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^10.0.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.0.0",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.3.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.53.0",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}
+1 -1
View File
@@ -1 +1 @@
42597f825aff483763c8cb00c83bfa74
3ca7ac3e41fb33a6fc3e30c16b39657b
+1156 -1180
View File
File diff suppressed because it is too large Load Diff
+56 -53
View File
@@ -1,11 +1,9 @@
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 { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
@@ -48,14 +46,18 @@ 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.6";
const download = useDownload();
const CURRENT_VERSION = __APP_VERSION__;
const download = useDownload(region);
const metadata = useMetadata();
const lyrics = useLyrics();
const cover = useCover();
@@ -117,6 +119,17 @@ function App() {
window.removeEventListener("scroll", handleScroll);
};
}, []);
const handleEnableSpotFetchApi = async () => {
try {
await updateSettings({ useSpotFetchAPI: true });
metadata.setShowApiModal(false);
toast.success("SpotFetch API enabled! You can now try fetching again.");
}
catch (err) {
console.error("Failed to enable SpotFetch API:", err);
toast.error("Failed to update settings");
}
};
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
@@ -288,16 +301,19 @@ function App() {
setSearchQuery(value);
setCurrentListPage(1);
};
const toggleTrackSelection = (isrc: string) => {
setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]);
const toggleTrackSelection = (id: string) => {
setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]);
};
const toggleSelectAll = (tracks: any[]) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
if (selectedTracks.length === tracksWithIsrc.length) {
setSelectedTracks([]);
const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || "");
if (tracksWithId.length === 0)
return;
const allSelected = tracksWithId.every(id => selectedTracks.includes(id));
if (allSelected) {
setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id)));
}
else {
setSelectedTracks(tracksWithIsrc);
setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId])));
}
};
const handleOpenFolder = async () => {
@@ -319,11 +335,12 @@ 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}/>);
const trackId = track.spotify_id || "";
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} 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) => {
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);
@@ -337,7 +354,7 @@ function App() {
}
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) => {
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);
@@ -351,7 +368,7 @@ function App() {
}
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) => {
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);
@@ -400,7 +417,10 @@ function App() {
case "about":
return <AboutPage version={CURRENT_VERSION}/>;
case "history":
return <HistoryPage />;
return <HistoryPage onHistorySelect={(cachedData) => {
metadata.loadFromCache(cachedData);
setCurrentPage("main");
}}/>;
case "audio-analysis":
return <AudioAnalysisPage />;
case "audio-converter":
@@ -412,42 +432,6 @@ function App() {
<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}>
@@ -487,7 +471,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()}
</>);
@@ -583,6 +567,25 @@ function App() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={metadata.showApiModal} onOpenChange={metadata.setShowApiModal}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>SpotFetch API Recommended</DialogTitle>
<DialogDescription>
Direct fetch failed. This usually happens when your <span className="text-foreground font-bold">country is blocked</span> by Spotify or your IP is restricted. Would you like to enable the <span className="text-foreground font-bold">SpotFetch API</span> to bypass this?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowApiModal(false)}>
Cancel
</Button>
<Button onClick={handleEnableSpotFetchApi}>
Enable SpotFetch API
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>);
}
+27
View File
@@ -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

+13
View File
@@ -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

+370 -213
View File
@@ -2,29 +2,32 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 } from "lucide-react";
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 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 [reportType, setReportType] = useState("bug");
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
const [bugType, setBugType] = useState("Track");
const [problem, setProblem] = useState("");
const [bugType, setBugType] = useState<string>("Track");
const [spotifyUrl, setSpotifyUrl] = useState("");
const [bugContext, setBugContext] = useState("");
const [featureDesc, setFeatureDesc] = useState("");
@@ -50,14 +53,14 @@ export function AboutPage({ version }: AboutPageProps) {
fetchOS();
const fetchLocation = async () => {
try {
const response = await fetch('https://ipapi.co/json/');
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 city = data.city || "";
const region = data.region || "";
const country = data.country_name || "";
const parts = [city, region, country].filter(Boolean);
setLocation(parts.join(', ') || 'Unknown');
setLocation(parts.join(", ") || "Unknown");
}
else {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -71,7 +74,7 @@ export function AboutPage({ version }: AboutPageProps) {
};
fetchLocation();
const fetchRepoStats = async () => {
const CACHE_KEY = 'github_repo_stats';
const CACHE_KEY = "github_repo_stats";
const CACHE_DURATION = 1000 * 60 * 60;
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
@@ -83,12 +86,13 @@ export function AboutPage({ version }: AboutPageProps) {
}
}
catch (err) {
console.error('Failed to parse cache:', err);
console.error("Failed to parse cache:", err);
}
}
const repos = [
{ name: 'SpotiDownloader', owner: 'afkarxyz' },
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' }
{ 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) {
@@ -96,7 +100,7 @@ export function AboutPage({ version }: AboutPageProps) {
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`)
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`),
]);
if (repoRes.status === 403) {
if (cached) {
@@ -112,9 +116,11 @@ export function AboutPage({ version }: AboutPageProps) {
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;
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);
return (sum +
(release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0));
}, 0);
}
const topLangs = Object.entries(languages)
@@ -127,7 +133,7 @@ export function AboutPage({ version }: AboutPageProps) {
createdAt: repoData.created_at,
totalDownloads,
latestDownloads,
languages: topLangs
languages: topLangs,
};
}
}
@@ -148,28 +154,25 @@ export function AboutPage({ version }: AboutPageProps) {
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."
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."
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."
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."
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."
}
a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source.",
},
];
const sanitizeForURL = (text: string): string => {
return text.replace(/[()]/g, "").replace(/,/g, " -");
};
const formatTimeAgo = (dateString: string): string => {
const now = new Date();
const updated = new Date(dateString);
@@ -177,13 +180,13 @@ export function AboutPage({ version }: AboutPageProps) {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffMonths = Math.floor(diffDays / 30);
if (diffDays === 0)
return 'today';
return "today";
if (diffDays === 1)
return '1d';
return "1d";
if (diffDays < 30)
return `${diffDays}d`;
if (diffMonths === 1)
return '1mo';
return "1mo";
if (diffMonths < 12)
return `${diffMonths}mo`;
const diffYears = Math.floor(diffMonths / 12);
@@ -196,213 +199,367 @@ export function AboutPage({ version }: AboutPageProps) {
return num.toString();
};
const getLangColor = (lang: string): string => {
return langColors[lang] || '#858585';
return langColors[lang] || "#858585";
};
const handleSubmit = () => {
let title = "";
let body = "";
if (reportType === "bug") {
title = `[Bug Report] ${problem ? problem.substring(0, 50) + (problem.length > 50 ? "..." : "") : "Issue"}`;
body = `### [Bug Report]
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"}
${problem || "Type here"}
#### Type
${bugType || "Track / Album / Playlist / Artist"}
${bugType}
#### Spotify URL
> ${spotifyUrl || "Type here"}
${spotifyUrl || "Type here"}
#### Additional Context
> ${bugContext || "Type here or send screenshot/recording"}
${contextContent}
#### Version
SpotiFLAC v${version}
#### OS
${sanitizeForURL(os || "Unknown")}
#### Location
${location || "Unknown"}
`;
#### Environment
- SpotiFLAC Version: ${version}
- OS: ${os}
- Location: ${location}`;
}
else {
title = `[Feature Request] ${featureDesc ? featureDesc.substring(0, 50) + (featureDesc.length > 50 ? "..." : "") : "Request"}`;
body = `### [Feature Request]
const contextContent = featureContext.trim()
? featureContext.trim()
: "Type here or send screenshot/recording";
bodyContent = `### [Feature Request]
#### Description
> ${featureDesc || "Type here"}
${featureDesc || "Type here"}
#### Use Case
> ${useCase || "Type here"}
${useCase || "Type here"}
#### Additional Context
> ${featureContext || "Type here or send screenshot/recording"}
`;
${contextContent}`;
}
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
const params = new URLSearchParams({
title: title,
body: bodyContent,
});
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
openExternal(url);
};
return (<div className="animate-in slide-in-from-bottom-12 fade-in duration-500 ease-out space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">About</h2>
</div>
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>
<Tabs defaultValue="report" className="w-full">
<TabsList className="grid w-full grid-cols-3 cursor-pointer">
<TabsTrigger value="report" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Report Issue</TabsTrigger>
<TabsTrigger value="faq" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">FAQ</TabsTrigger>
<TabsTrigger value="projects" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Other Projects</TabsTrigger>
</TabsList>
<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 Me
</Button>
</div>
<TabsContent value="report" className="mt-4">
<Card>
<CardContent className="space-y-4 pt-4">
<Tabs value={reportType} onValueChange={setReportType} className="w-full">
<TabsList className="w-full grid grid-cols-2 cursor-pointer pb-2">
<TabsTrigger value="bug" className="flex items-center gap-2 cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"><Bug className="h-4 w-4" /> Bug Report</TabsTrigger>
<TabsTrigger value="feature" className="flex items-center gap-2 cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"><Lightbulb className="h-4 w-4" /> Feature Request</TabsTrigger>
</TabsList>
<div className="mt-4">
{reportType === "bug" ? (<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2 flex flex-col">
<Label>Problem</Label>
<Textarea className="flex-1 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)} />
</div>
<div className="space-y-4">
<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 className="space-y-2 h-full">
<Label>Additional Context</Label>
<Textarea className="h-[125px] resize-none" placeholder="Any other details? Screenshots or recordings are very helpful (please upload directly to GitHub)." value={bugContext} onChange={e => setBugContext(e.target.value)} />
</div>
</div>
</div>) : (<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2 flex flex-col">
<Label>Description</Label>
<Textarea className="flex-1 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)} />
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label>Use Case</Label>
<Textarea className="h-[100px] resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Additional Context</Label>
<Textarea className="h-[135px] resize-none" placeholder="Any other details? Screenshots/recordings or examples..." value={featureContext} onChange={e => setFeatureContext(e.target.value)} />
</div>
</div>
</div>)}
</div>
</Tabs>
<div className="flex justify-center pt-2">
<Button className="w-[200px] cursor-pointer" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4" /> Create Issue on GitHub
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="faq" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Frequently Asked Questions</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
</div>))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="projects" className="mt-4 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://exyezed.cc/")}>
<CardHeader>
<CardTitle>Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex gap-3 pt-2">
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro" />
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS" />
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro" />
</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://spotubedl.com/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL" /> SpotubeDL</CardTitle>
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader" /> SpotiDownloader</CardTitle>
<CardDescription>Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.</CardDescription>
</CardHeader>
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500" /> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5" /> {repoStats['SpotiDownloader'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5" /> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5" /> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader" /> Twitter/X Media Batch Downloader</CardTitle>
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
</CardHeader>
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500" /> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5" /> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5" /> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5" /> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5" /> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
<div 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>
</TabsContent>
</Tabs>
</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 Me</h3>
<p className="text-muted-foreground max-w-[500px]">
If this software is useful and brings you value, consider
supporting the project on Ko-fi. Your support helps keep
development going.
</p>
</div>
<div className="flex justify-center 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>
</div>
</div>)}
</div>
</div>);
}
+11 -5
View File
@@ -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";
@@ -48,9 +48,9 @@ interface AlbumInfoProps {
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
@@ -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"/>)}
+173 -45
View File
@@ -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;
@@ -63,9 +67,9 @@ interface ArtistInfoProps {
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
@@ -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>)}
{artistInfo.biography && (<p className="text-sm text-white/90 line-clamp-4">{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>)}
{artistInfo.biography && (<p className="text-sm text-muted-foreground line-clamp-4">{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,98 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</div>
</div>)}
{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">
{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">
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">Discography</h3>
<div className="flex gap-2">
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Discography
</Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length})
</Button>)}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{albumList.map((album) => {
const albumTracks = trackList.filter(t => t.album_name === album.name);
const tracksWithId = albumTracks.filter(t => t.spotify_id);
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
const hasTracks = tracksWithId.length > 0;
return (<div key={album.id} className="group cursor-pointer relative" onClick={() => onAlbumClick({
id: album.id,
name: album.name,
external_urls: album.external_urls,
})}>
<div className="relative mb-2">
{hasTracks && (<div className={`absolute top-2 left-2 z-20 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`} onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => onToggleSelectAll(albumTracks)} className="bg-black/50 border-white/70 data-[state=checked]:bg-primary data-[state=checked]:border-primary"/>
</div>)}
{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">{album.name}</h4>
<p className="text-sm text-muted-foreground">
{album.release_date?.split("-")[0]}
</p>
</div>))}
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{album.release_date?.split("-")[0]}</span>
{album.total_tracks && (<>
<span></span>
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
</>)}
</div>
</div>);
})}
</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 tracksWithId = data.tracks.filter(t => t.spotify_id);
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
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
+38 -7
View File
@@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioFile {
@@ -152,6 +152,27 @@ export function AudioConverterPage() {
});
}
};
const handleSelectFolder = async () => {
try {
const selectedFolder = await SelectFolder("");
if (selectedFolder) {
const folderFiles = await ListAudioFilesInDir(selectedFolder);
if (folderFiles && folderFiles.length > 0) {
addFiles(folderFiles.map((f) => f.path));
}
else {
toast.info("No audio files found", {
description: "No FLAC or MP3 files found in the selected folder.",
});
}
}
}
catch (err) {
toast.error("Folder Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select folder",
});
}
};
const addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"];
const m4aFiles = paths.filter((path) => {
@@ -243,7 +264,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,
@@ -298,7 +319,11 @@ export function AudioConverterPage() {
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add More
Add Files
</Button>
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
<Upload className="h-4 w-4"/>
Add Folder
</Button>
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
<Trash2 className="h-4 w-4"/>
@@ -329,10 +354,16 @@ export function AudioConverterPage() {
? "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>
<div className="flex gap-3">
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/>
Select Files
</Button>
<Button onClick={handleSelectFolder} size="lg" variant="outline">
<Upload className="h-5 w-5"/>
Select Folder
</Button>
</div>
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3
</p>
+22 -1
View File
@@ -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
+38 -7
View File
@@ -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);
});
};
+1 -1
View File
@@ -35,7 +35,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer no account required.
</p>
</div>
</div>);
+519 -250
View File
@@ -1,12 +1,12 @@
import { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause } from "lucide-react";
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { 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 } from "../../wailsjs/go/main/App";
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) => {
@@ -19,7 +19,7 @@ const formatDate = (timestamp: number) => {
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
interface HistoryItem {
interface DownloadHistoryItem {
id: string;
spotify_id: string;
title: string;
@@ -32,65 +32,77 @@ interface HistoryItem {
path: string;
timestamp: number;
}
export function HistoryPage() {
const [history, setHistory] = useState<HistoryItem[]>([]);
const [filteredHistory, setFilteredHistory] = useState<HistoryItem[]>([]);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("default");
const [currentPage, setCurrentPage] = useState(1);
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 fetchHistory = async () => {
const fetchDownloadHistory = async () => {
try {
const items = await GetDownloadHistory();
setHistory(items || []);
setDownloadHistory(items || []);
}
catch (err) {
console.error("Failed to fetch history:", err);
console.error("Failed to fetch download history:", err);
}
};
const fetchFetchHistory = async () => {
try {
const items = await GetFetchHistory();
setFetchHistory(items || []);
}
catch (err) {
console.error("Failed to fetch fetch history:", err);
}
};
useEffect(() => {
fetchHistory();
const interval = setInterval(fetchHistory, 5000);
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 () => {
clearInterval(interval);
if (audioRef.current) {
audioRef.current.pause();
}
};
}, []);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
audioRef.current?.pause();
setPlayingPreviewId(null);
return;
}
if (audioRef.current) {
audioRef.current.pause();
}
try {
const url = await GetPreviewURL(spotifyId);
if (url) {
const audio = new Audio(url);
audioRef.current = audio;
audio.volume = 0.5;
audio.onended = () => setPlayingPreviewId(null);
audio.play();
setPlayingPreviewId(id);
}
} catch (e) {
console.error("Failed to play preview:", e);
}
};
useEffect(() => {
let result = [...history];
if (searchQuery) {
const query = searchQuery.toLowerCase();
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));
@@ -104,38 +116,86 @@ export function HistoryPage() {
return 0;
};
result.sort((a, b) => {
switch (sortBy) {
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;
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;
}
});
setFilteredHistory(result);
setCurrentPage(1);
}, [history, searchQuery, sortBy]);
const totalPages = Math.ceil(filteredHistory.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const paginatedHistory = filteredHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
if (total <= 10) {
return Array.from({ length: total }, (_, i) => i + 1);
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) {
@@ -159,188 +219,397 @@ export function HistoryPage() {
}
return pages;
};
const handleClearHistory = async () => {
await ClearDownloadHistory();
fetchHistory();
setShowClearConfirm(false);
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', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
</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 flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">Download History</h2>
{history.length > 0 && (<Badge variant="secondary" className="font-mono">
{history.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearConfirm(true)} disabled={history.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4" /> Clear
</Button>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">History</h1>
</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 history..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-8 h-9" />
<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>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px] h-9">
<ArrowUpDown className="mr-2 h-4 w-4" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="date_desc">Date (Newest)</SelectItem>
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-md border overflow-hidden">
{paginatedHistory.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-40 text-xs uppercase text-nowrap">Downloaded At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-20 text-xs uppercase text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{paginatedHistory.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }} />
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-sm truncate">{item.title}</span>
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
</div>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
</span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
{item.duration_str}
</td>
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
<div className="flex flex-col">
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
</div>
</td>
<td className="p-3 align-middle text-center">
<div className="flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
{playingPreviewId === item.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{activeTab === "downloads" && (<div className="mt-6">
{renderDownloadHistory()}
</div>)}
<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>
</div>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{activeTab === "fetches" && (<div className="mt-6">
{renderFetchHistory()}
</div>)}
{
totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} />
</PaginationItem>
<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>
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} />
</PaginationItem>
</PaginationContent>
</Pagination>)
}
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Clear Download History?</DialogTitle>
<DialogDescription>
This will remove all entries from your download history. This action cannot be undone.
Note: The actual downloaded files will NOT be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div >);
<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>);
}
@@ -16,3 +16,8 @@ export const AmazonIcon = ({ className = "w-4 h-4" }: {
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>);
export const DeezerIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 512 512" className={`${className} fill-current`}>
<path d="M14.8 101.1C6.6 101.1 0 127.6 0 160.3s6.6 59.2 14.8 59.2s14.8-26.5 14.8-59.2s-6.6-59.2-14.8-59.2m433.9-60.2c-7.7 0-14.5 17.1-19.4 44.1c-7.7-46.7-20.2-77-34.2-77c-16.8 0-31.1 42.9-38 105.4c-6.6-45.4-16.8-74.2-28.3-74.2c-16.1 0-29.6 56.9-34.7 136.2c-9.4-40.8-23.2-66.3-38.3-66.3s-28.8 25.5-38.3 66.3c-5.1-79.3-18.6-136.2-34.7-136.2c-11.5 0-21.7 28.8-28.3 74.2C147.9 50.9 133.3 8 116.7 8c-14 0-26.5 30.4-34.2 77c-4.8-27-11.7-44.1-19.4-44.1c-14.3 0-26 59.2-26 132.1S49 305.2 63.3 305.2c5.9 0 11.5-9.9 15.8-26.8c6.9 61.7 21.2 104.1 38 104.1c13 0 24.5-25.5 32.1-65.6c5.4 76.3 18.6 130.4 34.2 130.4c9.7 0 18.6-21.4 25.3-56.4c7.9 72.2 26.3 122.7 47.7 122.7s39.5-50.5 47.7-122.7c6.6 35 15.6 56.4 25.3 56.4c15.6 0 28.8-54.1 34.2-130.4c7.7 40.1 19.4 65.6 32.1 65.6c16.6 0 30.9-42.3 38-104.1c4.3 16.8 9.7 26.8 15.8 26.8c14.3 0 26-59.2 26-132.1S463 40.9 448.7 40.9m48.5 60.2c-8.2 0-14.8 26.5-14.8 59.2s6.6 59.2 14.8 59.2S512 193 512 160.3s-6.6-59.2-14.8-59.2"/>
</svg>);
+12 -6
View File
@@ -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";
@@ -54,9 +54,9 @@ interface PlaylistInfoProps {
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
@@ -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">
@@ -33,6 +33,7 @@ export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChang
<SelectItem value="plays-desc">Plays (High)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
<SelectItem value="failed">Failed Downloads</SelectItem>
</SelectContent>
</Select>
</div>);
+400 -107
View File
@@ -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,219 @@ 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";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
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 +238,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);
@@ -40,7 +255,11 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
artists: false,
playlists: false,
});
const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
const [invalidUrl, setInvalidUrl] = useState("");
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);
@@ -93,7 +312,10 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
const results = await SearchSpotify({
query: searchQuery,
limit: SEARCH_LIMIT,
});
setSearchResults(results);
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
@@ -149,10 +371,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
if (!prev)
return prev;
const updated = new backend.SearchResponse({
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
tracks: activeTab === "tracks"
? [...prev.tracks, ...moreResults]
: prev.tracks,
albums: activeTab === "albums"
? [...prev.albums, ...moreResults]
: prev.albums,
artists: activeTab === "artists"
? [...prev.artists, ...moreResults]
: prev.artists,
playlists: activeTab === "playlists"
? [...prev.playlists, ...moreResults]
: prev.playlists,
});
return updated;
});
@@ -169,6 +399,35 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
setIsLoadingMore(false);
}
};
const isSpotifyUrl = (text: string) => {
const trimmed = text.trim();
if (!trimmed)
return true;
const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed);
if (!isUrl)
return true;
return (trimmed.includes("spotify.com") ||
trimmed.includes("spotify.link") ||
trimmed.startsWith("spotify:"));
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchMode)
return;
const pastedText = e.clipboardData.getData("text");
if (pastedText && !isSpotifyUrl(pastedText)) {
e.preventDefault();
setInvalidUrl(pastedText);
setShowInvalidUrlDialog(true);
}
};
const handleFetchWithValidation = () => {
if (!isSpotifyUrl(url)) {
setInvalidUrl(url);
setShowInvalidUrlDialog(true);
return;
}
onFetch();
};
const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false);
onFetchUrl(externalUrl);
@@ -178,18 +437,23 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const hasAnyResults = searchResults &&
(searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults)
return 0;
switch (tab) {
case "tracks": return searchResults.tracks.length;
case "albums": return searchResults.albums.length;
case "artists": return searchResults.artists.length;
case "playlists": return searchResults.playlists.length;
case "tracks":
return searchResults.tracks.length;
case "albums":
return searchResults.albums.length;
case "artists":
return searchResults.artists.length;
case "playlists":
return searchResults.playlists.length;
}
};
const tabs: {
@@ -202,57 +466,51 @@ 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)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} 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}>
{!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={handleFetchWithValidation} disabled={loading}>
{loading ? (<>
<Spinner />
Fetching...
@@ -260,15 +518,13 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
<CloudDownload className="h-4 w-4"/>
Fetch
</>)}
</Button>)}
</div>
</Button>
</>)}
</div>
{!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">
@@ -294,7 +550,6 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
</div>)}
{!isSearching && hasAnyResults && (<>
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const count = getTabCount(tab.key);
@@ -308,54 +563,61 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
})}
</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>))}
{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>))}
{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 ? (<>
@@ -369,5 +631,36 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
</div>)}
</>)}
</div>)}
<Dialog open={showInvalidUrlDialog} onOpenChange={setShowInvalidUrlDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Invalid URL</DialogTitle>
<DialogDescription>
Only Spotify links are allowed in Fetch mode.
</DialogDescription>
</DialogHeader>
{invalidUrl && (<div className="p-3 bg-muted rounded-md border text-xs font-mono break-all opacity-70">
{invalidUrl}
</div>)}
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowInvalidUrlDialog(false);
setInvalidUrl("");
}}>
Cancel
</Button>
<Button onClick={() => {
onSearchModeChange(true);
setShowInvalidUrlDialog(false);
setInvalidUrl("");
}}>
Switch to Search
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
File diff suppressed because it is too large Load Diff
+95 -97
View File
@@ -17,108 +17,106 @@ interface SidebarProps {
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
<div className="flex flex-col gap-2 flex-1">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
<HomeIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Home</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<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 === "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 === "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>Download History</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 === "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 === "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-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 === "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 === "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 === "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>
<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>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Every coffee helps me keep going</p>
</TooltipContent>
</Tooltip>
</div>
</div>);
<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>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
</TooltipContent>
</Tooltip>
</div>
</div>);
}
+57 -5
View File
@@ -1,6 +1,30 @@
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 handleSettingsUpdate = (event: any) => {
const updatedSettings = event.detail;
if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') {
setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI);
}
};
window.addEventListener('settingsUpdated', handleSettingsUpdate);
return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate);
}, []);
const handleSpotFetchAPIToggle = () => {
const newValue = !useSpotFetchAPI;
setUseSpotFetchAPI(newValue);
updateSettings({ useSpotFetchAPI: newValue });
};
const handleMinimize = () => {
WindowMinimise();
};
@@ -11,11 +35,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>
+17 -9
View File
@@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackInfoProps {
track: TrackMetadata & {
@@ -26,13 +26,14 @@ interface TrackInfoProps {
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownload: (id: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onCheckAvailability?: (spotifyId: string) => 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>
@@ -88,9 +95,9 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</div>)}
</div>
</div>
{track.isrc && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
{track.spotify_id && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.spotify_id || "", track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.spotify_id}>
{downloadingTrack === track.spotify_id ? (<Spinner />) : (<>
<Download className="h-4 w-4"/>
Download
</>)}
@@ -127,7 +134,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" size="icon" disabled={checkingAvailability}>
<Button onClick={() => onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
@@ -136,6 +143,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
+28 -18
View File
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackListProps {
tracks: TrackMetadata[];
@@ -33,11 +33,11 @@ interface TrackListProps {
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleTrack: (id: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: {
@@ -104,18 +104,25 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
}
else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
});
}
else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
else if (sortBy === "failed") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
return (bFailed ? 1 : 0) - (aFailed ? 1 : 0);
});
}
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
@@ -149,9 +156,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
}
return pages;
};
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
const allSelected = tracksWithIsrc.length > 0 &&
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
const tracksWithId = filteredTracks.filter((track) => track.spotify_id);
const allSelected = tracksWithId.length > 0 &&
tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
@@ -197,7 +204,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
<tbody>
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle">
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-0.5">
@@ -221,7 +228,9 @@ 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>)}
{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}
{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>)}
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ((() => {
@@ -268,14 +277,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</td>
<td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1">
{track.isrc && (<Tooltip>
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
<Button onClick={() => onDownloadTrack(track.spotify_id!, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.spotify_id}>
{downloadingTrack === track.spotify_id ? (<Spinner />) : skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
{downloadingTrack === track.spotify_id ? (<p>Downloading...</p>) : skippedTracks.has(track.spotify_id) ? (<p>Already exists</p>) : downloadedTracks.has(track.spotify_id) ? (<p>Downloaded</p>) : failedTracks.has(track.spotify_id) ? (<p>Failed</p>) : (<p>Download Track</p>)}
</TooltipContent>
</Tooltip>)}
{track.spotify_id && (<Tooltip>
@@ -313,7 +322,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
<Button onClick={() => onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
@@ -322,6 +331,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
+60
View File
@@ -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 };
+2 -2
View File
@@ -7,7 +7,7 @@ export function useAvailability() {
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
const [error, setError] = useState<string | null>(null);
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
const checkAvailability = useCallback(async (spotifyId: string) => {
if (!spotifyId) {
setError("No Spotify ID provided");
return null;
@@ -20,7 +20,7 @@ export function useAvailability() {
setError(null);
try {
logger.info(`Checking availability for track: ${spotifyId}`);
const response = await CheckTrackAvailability(spotifyId, isrc || "");
const response = await CheckTrackAvailability(spotifyId);
const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => {
const newMap = new Map(prev);
+19 -7
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import { downloadCover } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
export function useCover() {
@@ -28,11 +28,17 @@ export function useCover() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__";
const yearValue = releaseDate?.substring(0, 4);
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: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder),
};
const folderTemplate = settings.folderTemplate || "";
@@ -53,9 +59,9 @@ export function useCover() {
const response = await downloadCover({
cover_url: coverUrl,
track_name: trackName,
artist_name: artistName,
artist_name: displayArtist,
album_name: albumName || "",
album_artist: albumArtist || "",
album_artist: displayAlbumArtist || "",
release_date: releaseDate || "",
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
@@ -124,11 +130,17 @@ 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 displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
artist: displayArtist?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: trackPosition,
year: yearValue,
date: track.release_date,
playlist: playlistName?.replace(/\//g, placeholder),
};
const folderTemplate = settings.folderTemplate || "";
@@ -149,9 +161,9 @@ export function useCover() {
const response = await downloadCover({
cover_url: track.images,
track_name: track.name,
artist_name: track.artists,
artist_name: displayArtist,
album_name: track.album_name,
album_artist: track.album_artist,
album_artist: displayAlbumArtist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
+332 -122
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
interface CheckFileExistenceRequest {
@@ -19,6 +19,7 @@ interface CheckFileExistenceRequest {
filename_format?: string;
include_track_number?: boolean;
audio_format?: string;
relative_path?: string;
}
interface FileExistenceResult {
spotify_id: string;
@@ -27,9 +28,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);
@@ -42,7 +44,7 @@ export function useDownload() {
artists: string;
} | null>(null);
const shouldStopDownloadRef = useRef(false);
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem;
@@ -73,18 +75,25 @@ 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,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder),
};
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) {
@@ -102,11 +111,11 @@ export function useDownload() {
if (trackName && artistName) {
try {
const checkRequest: CheckFileExistenceRequest = {
spotify_id: spotifyId || isrc,
spotify_id: spotifyId || id,
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 +125,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 +143,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(id, 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) {
@@ -151,21 +160,21 @@ export function useDownload() {
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6";
const qobuzQuality = is24Bit ? "27" : "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,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -186,16 +195,22 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`tidal failed, trying next...`);
}
catch (err) {
logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -203,13 +218,12 @@ export function useDownload() {
try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
isrc,
service: "amazon",
query,
track_name: trackName,
artist_name: artistName,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -228,16 +242,21 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response;
logger.warning(`amazon failed, trying next...`);
}
catch (err) {
logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -245,13 +264,12 @@ export function useDownload() {
try {
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
isrc,
service: "qobuz",
query,
track_name: trackName,
artist_name: artistName,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -270,23 +288,77 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response;
logger.warning(`qobuz failed, trying next...`);
}
catch (err) {
logger.error(`qobuz error: ${err}`);
fallbackErrors.push(`[Qobuz] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
else if (s === "deezer") {
try {
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "deezer",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
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,
duration: durationSeconds,
item_id: itemID,
audio_format: "flac",
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`deezer: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Deezer] ${errMsg}`);
lastResponse = response;
logger.warning(`deezer failed, trying next...`);
}
catch (err) {
logger.error(`deezer error: ${err}`);
fallbackErrors.push(`[Deezer] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
}
if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
await MarkDownloadItemFailed(itemID, finalError);
}
return lastResponse;
}
@@ -298,14 +370,17 @@ export function useDownload() {
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
else if (service === "deezer") {
audioFormat = "flac";
}
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "tidal" | "qobuz" | "amazon",
service: service as "tidal" | "qobuz" | "amazon" | "deezer",
query,
track_name: trackName,
artist_name: artistName,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -325,6 +400,8 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
@@ -332,7 +409,7 @@ export function useDownload() {
}
return singleServiceResponse;
};
const downloadWithItemID = async (isrc: string, settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
@@ -360,20 +437,25 @@ export function useDownload() {
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
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,
date: releaseDate,
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) {
@@ -391,7 +473,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) {
@@ -401,20 +483,21 @@ export function useDownload() {
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" };
const fallbackErrors: string[] = [];
const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6";
const qobuzQuality = is24Bit ? "27" : "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,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -435,27 +518,35 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`tidal failed, trying next...`);
}
catch (err) {
console.error("Tidal error:", err);
logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
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,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -474,27 +565,35 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Amazon] ${errMsg}`);
lastResponse = response;
logger.warning(`amazon failed, trying next...`);
}
catch (err) {
console.error("Amazon error:", err);
logger.error(`amazon error: ${err}`);
fallbackErrors.push(`[Amazon] ${String(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,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -514,21 +613,78 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`qobuz: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Qobuz] ${errMsg}`);
lastResponse = response;
logger.warning(`qobuz failed, trying next...`);
}
catch (err) {
console.error("Qobuz error:", err);
logger.error(`qobuz error: ${err}`);
fallbackErrors.push(`[Qobuz] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
else if (s === "deezer") {
try {
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "deezer",
query,
track_name: trackName,
artist_name: displayArtist,
album_name: albumName,
album_artist: displayAlbumArtist,
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: "flac",
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`deezer: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Deezer] ${errMsg}`);
lastResponse = response;
logger.warning(`deezer failed, trying next...`);
}
catch (err) {
logger.error(`deezer error: ${err}`);
fallbackErrors.push(`[Deezer] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
}
if (!lastResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
await MarkDownloadItemFailed(itemID, finalError);
}
return lastResponse;
}
@@ -540,14 +696,16 @@ export function useDownload() {
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
}
else if (service === "deezer") {
audioFormat = "flac";
}
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "tidal" | "qobuz" | "amazon",
service: service as "tidal" | "qobuz" | "amazon" | "deezer",
query,
track_name: trackName,
artist_name: artistName,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: finalReleaseDate || releaseDate,
cover_url: coverUrl,
output_dir: outputDir,
@@ -567,6 +725,9 @@ export function useDownload() {
spotify_total_discs: spotifyTotalDiscs,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
if (!singleServiceResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
@@ -574,40 +735,41 @@ export function useDownload() {
}
return singleServiceResponse;
};
const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
if (!isrc) {
toast.error("No ISRC found for this track");
const handleDownloadTrack = async (id: string, trackName?: string, artistName?: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
if (!id) {
toast.error("No ID found for this track");
return;
}
logger.info(`starting download: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingTrack(isrc);
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
logger.info(`starting download: ${trackName} - ${displayArtist}`);
setDownloadingTrack(id);
try {
const releaseYear = releaseDate?.substring(0, 4);
const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName, albumName, playlistName, position, spotifyId, durationMs, releaseYear, albumArtist || "", releaseDate, coverUrl, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, copyright, publisher);
const response = await downloadWithAutoFallback(id, settings, trackName, artistName, albumName, playlistName, position, spotifyId, durationMs, releaseYear, albumArtist || "", releaseDate, coverUrl, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, copyright, publisher);
if (response.success) {
if (response.already_exists) {
toast.info(response.message);
setSkippedTracks((prev) => new Set(prev).add(isrc));
setSkippedTracks((prev) => new Set(prev).add(id));
}
else {
toast.success(response.message);
}
setDownloadedTracks((prev) => new Set(prev).add(isrc));
setDownloadedTracks((prev) => new Set(prev).add(id));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(isrc);
newSet.delete(id);
return newSet;
});
}
else {
toast.error(response.error || "Download failed");
setFailedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => new Set(prev).add(id));
}
}
catch (err) {
toast.error(err instanceof Error ? err.message : "Download failed");
setFailedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => new Set(prev).add(id));
}
finally {
setDownloadingTrack(null);
@@ -626,22 +788,24 @@ 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
.map((isrc) => allTracks.find((t) => t.isrc === isrc))
.map((id) => allTracks.find((t) => t.spotify_id === id))
.filter((t): t is TrackMetadata => t !== undefined);
logger.info(`checking existing files in parallel...`);
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const audioFormat = "flac";
const existenceChecks = selectedTrackObjects.map((track, index) => {
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
return {
spotify_id: track.spotify_id || track.isrc,
spotify_id: track.spotify_id || "",
track_name: track.name || "",
artist_name: track.artists || "",
artist_name: displayArtist || "",
album_name: track.album_name || "",
album_artist: track.album_artist || "",
album_artist: displayAlbumArtist || "",
release_date: track.release_date || "",
track_number: track.track_number || 0,
disc_number: track.disc_number || 0,
@@ -652,32 +816,37 @@ 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`);
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const isrc of selectedTracks) {
const track = allTracks.find((t) => t.isrc === isrc);
const trackID = track?.spotify_id || isrc;
const itemID = await AddToDownloadQueue(trackID, track?.name || "", track?.artists || "", track?.album_name || "");
for (const id of selectedTracks) {
const track = allTracks.find((t) => t.spotify_id === id);
if (!track)
continue;
const trackID = track.spotify_id || id;
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
const itemID = await AddToDownloadQueue(trackID, track.name || "", displayArtist || "", track.album_name || "");
itemIDs.push(itemID);
if (existingSpotifyIDs.has(trackID)) {
const filePath = existingFilePaths.get(trackID) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(isrc));
setDownloadedTracks((prev) => new Set(prev).add(isrc));
setSkippedTracks((prev) => new Set(prev).add(id));
setDownloadedTracks((prev) => new Set(prev).add(id));
}
}
const tracksToDownload = selectedTrackObjects.filter((track) => {
const trackID = track.spotify_id || track.isrc;
const trackID = track.spotify_id || "";
return !existingSpotifyIDs.has(trackID);
});
let successCount = 0;
@@ -691,41 +860,46 @@ export function useDownload() {
break;
}
const track = tracksToDownload[i];
const isrc = track.isrc;
const originalIndex = selectedTracks.indexOf(isrc);
const id = track.spotify_id || "";
const originalIndex = selectedTracks.indexOf(id);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
setDownloadingTrack(id);
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
setCurrentDownloadInfo({ name: track.name, artists: displayArtist || "" });
try {
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(isrc, settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
if (response.success) {
if (response.already_exists) {
skippedCount++;
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(isrc));
logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(id));
}
else {
successCount++;
logger.success(`downloaded: ${track.name} - ${track.artists}`);
logger.success(`downloaded: ${track.name} - ${displayArtist}`);
}
setDownloadedTracks((prev) => new Set(prev).add(isrc));
if (response.file) {
finalFilePaths.set(id, response.file);
finalFilePaths.set(track.spotify_id || id, response.file);
}
setDownloadedTracks((prev) => new Set(prev).add(id));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(isrc);
newSet.delete(id);
return newSet;
});
}
else {
errorCount++;
logger.error(`failed: ${track.name} - ${track.artists}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
logger.error(`failed: ${track.name} - ${displayArtist}`);
setFailedTracks((prev) => new Set(prev).add(id));
}
}
catch (err) {
errorCount++;
logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => new Set(prev).add(id));
if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
@@ -741,6 +915,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 || "") || "").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`);
@@ -762,12 +950,12 @@ export function useDownload() {
}
};
const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc);
if (tracksWithIsrc.length === 0) {
const tracksWithId = tracks.filter((track) => track.spotify_id);
if (tracksWithId.length === 0) {
toast.error("No tracks available for download");
return;
}
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
logger.info(`starting batch download: ${tracksWithId.length} tracks`);
const settings = getSettings();
setIsDownloading(true);
setBulkDownloadType("all");
@@ -775,19 +963,21 @@ 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...`);
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const audioFormat = "flac";
const existenceChecks = tracksWithIsrc.map((track, index) => {
const existenceChecks = tracksWithId.map((track, index) => {
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
return {
spotify_id: track.spotify_id || track.isrc,
spotify_id: track.spotify_id || "",
track_name: track.name || "",
artist_name: track.artists || "",
artist_name: displayArtist || "",
album_name: track.album_name || "",
album_artist: track.album_artist || "",
album_artist: displayAlbumArtist || "",
release_date: track.release_date || "",
track_number: track.track_number || 0,
disc_number: track.disc_number || 0,
@@ -798,37 +988,41 @@ 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(tracksWithId.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`);
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const track of tracksWithIsrc) {
const itemID = await AddToDownloadQueue(track.isrc, track.name, track.artists, track.album_name || "");
for (const track of tracksWithId) {
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
const itemID = await AddToDownloadQueue(track.spotify_id || "", track.name || "", displayArtist || "", track.album_name || "");
itemIDs.push(itemID);
const trackID = track.spotify_id || track.isrc;
const trackID = track.spotify_id || "";
if (existingSpotifyIDs.has(trackID)) {
const filePath = existingFilePaths.get(trackID) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
setSkippedTracks((prev: Set<string>) => new Set(prev).add(trackID));
setDownloadedTracks((prev: Set<string>) => new Set(prev).add(trackID));
}
}
const tracksToDownload = tracksWithIsrc.filter((track) => {
const trackID = track.spotify_id || track.isrc;
const tracksToDownload = tracksWithId.filter((track) => {
const trackID = track.spotify_id || "";
return !existingSpotifyIDs.has(trackID);
});
let successCount = 0;
let errorCount = 0;
let skippedCount = existingSpotifyIDs.size;
const total = tracksWithIsrc.length;
const total = tracksWithId.length;
setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
@@ -836,40 +1030,45 @@ export function useDownload() {
break;
}
const track = tracksToDownload[i];
const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc);
const originalIndex = tracksWithId.findIndex((t) => t.spotify_id === track.spotify_id);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
const trackId = track.spotify_id || "";
setDownloadingTrack(trackId);
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
setCurrentDownloadInfo({ name: track.name || "", artists: displayArtist || "" });
try {
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID(track.isrc, settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
if (response.success) {
if (response.already_exists) {
skippedCount++;
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(trackId));
}
else {
successCount++;
logger.success(`downloaded: ${track.name} - ${track.artists}`);
logger.success(`downloaded: ${track.name} - ${displayArtist}`);
}
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
setDownloadedTracks((prev) => new Set(prev).add(trackId));
setFailedTracks((prev) => {
const newSet = new Set(prev);
newSet.delete(track.isrc);
newSet.delete(trackId);
return newSet;
});
if (response.file) {
finalFilePaths[originalIndex] = response.file;
}
}
else {
errorCount++;
logger.error(`failed: ${track.name} - ${track.artists}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc));
logger.error(`failed: ${track.name} - ${displayArtist}`);
setFailedTracks((prev) => new Set(prev).add(trackId));
}
}
catch (err) {
errorCount++;
logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc));
setFailedTracks((prev) => new Set(prev).add(trackId));
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
@@ -883,6 +1082,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`);
+19 -7
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import { downloadLyrics } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
export function useLyrics() {
@@ -25,11 +25,17 @@ export function useLyrics() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__";
const yearValue = releaseDate?.substring(0, 4);
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: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
year: yearValue,
date: releaseDate,
playlist: playlistName?.replace(/\//g, placeholder),
};
const folderTemplate = settings.folderTemplate || "";
@@ -51,9 +57,9 @@ export function useLyrics() {
const response = await downloadLyrics({
spotify_id: spotifyId,
track_name: trackName,
artist_name: artistName,
artist_name: displayArtist,
album_name: albumName,
album_artist: albumArtist,
album_artist: displayAlbumArtist,
release_date: releaseDate,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
@@ -120,11 +126,17 @@ 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 displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
artist: displayArtist?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: trackPosition,
year: yearValue,
date: track.release_date,
playlist: playlistName?.replace(/\//g, placeholder),
};
const folderTemplate = settings.folderTemplate || "";
@@ -145,9 +157,9 @@ export function useLyrics() {
const response = await downloadLyrics({
spotify_id: id,
track_name: track.name,
artist_name: track.artists,
artist_name: displayArtist,
album_name: track.album_name,
album_artist: track.album_artist,
album_artist: displayAlbumArtist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
+91 -44
View File
@@ -1,14 +1,14 @@
import { useState } from "react";
import { getSettings } from "@/lib/settings";
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 [showApiModal, setShowApiModal] = useState(false);
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{
id: string;
@@ -27,6 +27,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 +86,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,9 +108,10 @@ 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`);
logger.debug(`duration: ${data.track.duration_ms}ms`);
}
else if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
@@ -78,12 +131,29 @@ export function useMetadata() {
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
const settings = getSettings();
if (!settings.useSpotFetchAPI) {
setShowApiModal(true);
}
else {
toast.error(errorMsg);
}
}
finally {
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 +167,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 +192,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 +220,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}`);
@@ -190,7 +232,13 @@ export function useMetadata() {
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
const settings = getSettings();
if (!settings.useSpotFetchAPI) {
setShowApiModal(true);
}
else {
toast.error(errorMsg);
}
}
finally {
setLoading(false);
@@ -200,18 +248,17 @@ export function useMetadata() {
return {
loading,
metadata,
showTimeoutDialog,
setShowTimeoutDialog,
timeoutValue,
setTimeoutValue,
showAlbumDialog,
setShowAlbumDialog,
selectedAlbum,
pendingArtistName,
handleFetchMetadata,
handleConfirmFetch,
handleAlbumClick,
handleConfirmAlbumFetch,
handleArtistClick,
loadFromCache,
showApiModal,
setShowApiModal,
resetMetadata: () => setMetadata(null),
};
}
+9 -1
View File
@@ -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) {
+35
View File
@@ -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;
}
+3
View File
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
}
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
if (request.use_single_genre !== undefined) {
(req as any).use_single_genre = request.use_single_genre;
}
return await DownloadTrack(req);
}
export async function checkHealth(): Promise<HealthResponse> {
+46 -11
View File
@@ -4,7 +4,7 @@ export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-ar
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export interface Settings {
downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon";
downloader: "auto" | "tidal" | "qobuz" | "amazon" | "deezer";
theme: string;
themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
@@ -21,10 +21,18 @@ export interface Settings {
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7";
qobuzQuality: "6" | "7" | "27";
amazonQuality: "original";
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
autoOrder: "tidal-qobuz-amazon-deezer" | "tidal-qobuz-deezer-amazon" | "tidal-amazon-qobuz-deezer" | "tidal-amazon-deezer-qobuz" | "tidal-deezer-qobuz-amazon" | "tidal-deezer-amazon-qobuz" | "qobuz-tidal-amazon-deezer" | "qobuz-tidal-deezer-amazon" | "qobuz-amazon-tidal-deezer" | "qobuz-amazon-deezer-tidal" | "qobuz-deezer-tidal-amazon" | "qobuz-deezer-amazon-tidal" | "amazon-tidal-qobuz-deezer" | "amazon-tidal-deezer-qobuz" | "amazon-qobuz-tidal-deezer" | "amazon-qobuz-deezer-tidal" | "amazon-deezer-tidal-qobuz" | "amazon-deezer-qobuz-tidal" | "deezer-tidal-qobuz-amazon" | "deezer-tidal-amazon-qobuz" | "deezer-qobuz-tidal-amazon" | "deezer-qobuz-amazon-tidal" | "deezer-amazon-tidal-qobuz" | "deezer-amazon-qobuz-tidal" | string;
autoQuality: "16" | "24";
allowFallback: boolean;
useSpotFetchAPI: boolean;
spotFetchAPIUrl: string;
createPlaylistFolder: boolean;
createM3u8File: boolean;
useFirstArtistOnly: boolean;
useSingleGenre: boolean;
embedGenre: boolean;
}
export const FOLDER_PRESETS: Record<FolderPreset, {
label: string;
@@ -72,6 +80,7 @@ export const TEMPLATE_VARIABLES = [
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
];
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
@@ -98,8 +107,16 @@ export const DEFAULT_SETTINGS: Settings = {
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon",
autoQuality: "16"
autoOrder: "tidal-qobuz-amazon-deezer",
autoQuality: "16",
allowFallback: true,
useSpotFetchAPI: false,
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
createPlaylistFolder: true,
createM3u8File: false,
useFirstArtistOnly: false,
useSingleGenre: false,
embedGenre: true
};
export const FONT_OPTIONS: {
value: FontFamily;
@@ -194,9 +211,6 @@ function getSettingsFromLocalStorage(): Settings {
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original";
}
@@ -206,6 +220,9 @@ function getSettingsFromLocalStorage(): Settings {
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
if (!('allowFallback' in parsed)) {
parsed.allowFallback = true;
}
return { ...DEFAULT_SETTINGS, ...parsed };
}
}
@@ -270,9 +287,6 @@ export async function loadSettings(): Promise<Settings> {
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original";
}
@@ -282,6 +296,24 @@ export async function loadSettings(): Promise<Settings> {
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;
}
if (!('useSingleGenre' in parsed)) {
parsed.useSingleGenre = false;
}
if (!('embedGenre' in parsed)) {
parsed.embedGenre = true;
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!;
}
@@ -307,6 +339,7 @@ export interface TemplateData {
track?: number;
disc?: number;
year?: string;
date?: string;
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
@@ -320,6 +353,7 @@ export function parseTemplate(template: string, data: TemplateData): string {
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{date\}/g, data.date || "0000-00-00");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
}
@@ -336,6 +370,7 @@ export async function saveSettings(settings: Settings): Promise<void> {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
await SaveToBackend(settings as any);
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
}
catch (error) {
console.error("Failed to save settings:", error);
+8 -1
View File
@@ -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, "_");
}
@@ -46,3 +46,10 @@ export function openExternal(url: string) {
}
}
}
export 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();
}
+7 -3
View File
@@ -16,7 +16,6 @@ export interface TrackMetadata {
total_discs?: number;
disc_number?: number;
external_urls: string;
isrc: string;
album_type?: string;
spotify_id?: string;
album_id?: string;
@@ -28,6 +27,7 @@ export interface TrackMetadata {
publisher?: string;
plays?: string;
status?: string;
is_explicit?: boolean;
}
export interface TrackResponse {
track: TrackMetadata;
@@ -108,8 +108,7 @@ export interface ArtistResponse {
}
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
export interface DownloadRequest {
isrc: string;
service: "tidal" | "qobuz" | "amazon";
service: "tidal" | "qobuz" | "amazon" | "deezer";
query?: string;
track_name?: string;
artist_name?: string;
@@ -138,6 +137,9 @@ export interface DownloadRequest {
copyright?: string;
publisher?: string;
spotify_url?: string;
use_first_artist_only?: boolean;
use_single_genre?: boolean;
embed_genre?: boolean;
}
export interface DownloadResponse {
success: boolean;
@@ -202,9 +204,11 @@ export interface TrackAvailability {
tidal: boolean;
amazon: boolean;
qobuz: boolean;
deezer: boolean;
tidal_url?: string;
amazon_url?: string;
qobuz_url?: string;
deezer_url?: string;
}
export interface CoverDownloadRequest {
cover_url: string;
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+7
View File
@@ -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),
},
});
+3 -3
View File
@@ -1,6 +1,6 @@
module spotiflac
module github.com/afkarxyz/SpotiFLAC
go 1.25.5
go 1.26
require (
github.com/bogem/id3v2/v2 v2.1.4
@@ -12,6 +12,7 @@ require (
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0
go.etcd.io/bbolt v1.4.3
golang.org/x/text v0.31.0
)
require (
@@ -45,5 +46,4 @@ require (
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
)
+16
View File
@@ -2,8 +2,11 @@ package main
import (
"embed"
"encoding/json"
"log"
"github.com/afkarxyz/SpotiFLAC/backend"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
@@ -13,8 +16,21 @@ import (
//go:embed all:frontend/dist
var assets embed.FS
//go:embed wails.json
var wailsJSON []byte
func main() {
type wailsInfo struct {
Info struct {
ProductVersion string `json:"productVersion"`
} `json:"info"`
}
var config wailsInfo
if err := json.Unmarshal(wailsJSON, &config); err == nil && config.Info.ProductVersion != "" {
backend.AppVersion = config.Info.ProductVersion
}
app := NewApp()
err := wails.Run(&options.App{
+2 -2
View File
@@ -12,10 +12,10 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "7.0.6",
"productVersion": "7.1.0",
"copyright": "© 2026 afkarxyz"
},
"wailsjsdir": "./frontend",
"assetdir": "./frontend/dist",
"reloaddirs": "./frontend/src"
}
}